From 2a5985f6efa4662015e3716f336a81ef1008581c Mon Sep 17 00:00:00 2001
From: taitus
Date: Wed, 16 Jul 2025 12:02:24 +0200
Subject: [PATCH 01/15] Update tests for votation type form behavior
Ensure the form toggles descriptions and fields correctly depending on the selected
votation type.
---
spec/system/admin/poll/questions_spec.rb | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/spec/system/admin/poll/questions_spec.rb b/spec/system/admin/poll/questions_spec.rb
index 127dca219..2a504de98 100644
--- a/spec/system/admin/poll/questions_spec.rb
+++ b/spec/system/admin/poll/questions_spec.rb
@@ -71,12 +71,17 @@ describe "Admin poll questions", :admin do
poll = create(:poll, :future)
visit admin_poll_path(poll)
click_link "Create question"
+ expect(page).to have_content "It's only possible to answer one time to the question."
end
scenario "Unique" do
fill_in "Question", with: "Question with unique answer"
select "Unique answer", from: "Votation type"
+ expect(page).to have_content "It's only possible to answer one time to the question."
+ expect(page).not_to have_content "Allows to choose multiple answers."
+ expect(page).not_to have_field "Maximum number of votes"
+
click_button "Save"
expect(page).to have_content "Question with unique answer"
@@ -86,6 +91,10 @@ describe "Admin poll questions", :admin do
scenario "Multiple" do
fill_in "Question", with: "Question with multiple answers"
select "Multiple answers", from: "Votation type"
+
+ expect(page).not_to have_content "It's only possible to answer one time to the question."
+ expect(page).to have_content "Allows to choose multiple answers."
+
fill_in "Maximum number of votes", with: 6
click_button "Save"
From eb10a3135b71248220032bc4b3c15e352c3b4b3c Mon Sep 17 00:00:00 2001
From: taitus
Date: Fri, 10 Oct 2025 13:07:53 +0200
Subject: [PATCH 02/15] Refactor vote type descriptions to use data attributes
This makes it easier to extend support for new types (e.g., 'open')
without adding more conditional logic to the JavaScript.
---
.../javascripts/admin/votation_types/fields.js | 18 ++++++++++--------
.../votation_types/fields_component.html.erb | 9 +++------
.../admin/votation_types/fields_component.rb | 13 +++++++++++++
3 files changed, 26 insertions(+), 14 deletions(-)
diff --git a/app/assets/javascripts/admin/votation_types/fields.js b/app/assets/javascripts/admin/votation_types/fields.js
index 85649e675..378d7474b 100644
--- a/app/assets/javascripts/admin/votation_types/fields.js
+++ b/app/assets/javascripts/admin/votation_types/fields.js
@@ -2,16 +2,18 @@
"use strict";
App.AdminVotationTypesFields = {
adjustForm: function() {
- if ($(this).val() === "unique") {
- $(".max-votes").hide();
- $(".description-unique").show();
- $(".description-multiple").hide();
- $(".votation-type-max-votes").prop("disabled", true);
- } else {
+ var select_field = $(this);
+
+ $("[data-vote-type]").hide(0, function() {
+ $("[data-vote-type=" + select_field.val() + "]").show();
+ });
+
+ if (select_field.val() === "multiple") {
$(".max-votes").show();
- $(".description-unique").hide();
- $(".description-multiple").show();
$(".votation-type-max-votes").prop("disabled", false);
+ } else {
+ $(".max-votes").hide();
+ $(".votation-type-max-votes").prop("disabled", true);
}
},
initialize: function() {
diff --git a/app/components/admin/votation_types/fields_component.html.erb b/app/components/admin/votation_types/fields_component.html.erb
index f55d74579..89183ee8b 100644
--- a/app/components/admin/votation_types/fields_component.html.erb
+++ b/app/components/admin/votation_types/fields_component.html.erb
@@ -4,12 +4,9 @@
-
- <%= t("admin.polls.votation_type.unique_description") %>
-
-
- <%= t("admin.polls.votation_type.multiple_description") %>
-
+ <% descriptions.each do |vote_type, text| %>
+ <%= description_tag(vote_type, text) %>
+ <% end %>
diff --git a/app/components/admin/votation_types/fields_component.rb b/app/components/admin/votation_types/fields_component.rb
index 6fe487397..a51a795a5 100644
--- a/app/components/admin/votation_types/fields_component.rb
+++ b/app/components/admin/votation_types/fields_component.rb
@@ -4,4 +4,17 @@ class Admin::VotationTypes::FieldsComponent < ApplicationComponent
def initialize(form:)
@form = form
end
+
+ private
+
+ def descriptions
+ {
+ unique: t("admin.polls.votation_type.unique_description"),
+ multiple: t("admin.polls.votation_type.multiple_description")
+ }
+ end
+
+ def description_tag(vote_type, text)
+ tag.span(text, data: { vote_type: vote_type })
+ end
end
From 4e57e311dc84cbf0124a6da04350deef1835ed85 Mon Sep 17 00:00:00 2001
From: taitus
Date: Wed, 6 Aug 2025 11:12:08 +0200
Subject: [PATCH 03/15] Add support for open-ended questions in admin section
Introduce a new "open" votation type for poll questions in the admin
interface. This type allows open answers provided by the user.
---
.../admin/votation_types/fields_component.rb | 3 ++-
app/models/poll/question.rb | 2 +-
app/models/votation_type.rb | 2 +-
config/locales/en/activerecord.yml | 1 +
config/locales/en/admin.yml | 1 +
config/locales/es/activerecord.yml | 1 +
config/locales/es/admin.yml | 1 +
spec/factories/polls.rb | 6 +++++
spec/factories/votation_type.rb | 4 +++
spec/models/poll/answer_spec.rb | 7 +++++
spec/models/votation_type_spec.rb | 6 ++++-
spec/system/admin/poll/questions_spec.rb | 27 ++++++++++++++++++-
12 files changed, 56 insertions(+), 5 deletions(-)
diff --git a/app/components/admin/votation_types/fields_component.rb b/app/components/admin/votation_types/fields_component.rb
index a51a795a5..dc2fc09a0 100644
--- a/app/components/admin/votation_types/fields_component.rb
+++ b/app/components/admin/votation_types/fields_component.rb
@@ -10,7 +10,8 @@ class Admin::VotationTypes::FieldsComponent < ApplicationComponent
def descriptions
{
unique: t("admin.polls.votation_type.unique_description"),
- multiple: t("admin.polls.votation_type.multiple_description")
+ multiple: t("admin.polls.votation_type.multiple_description"),
+ open: t("admin.polls.votation_type.open_description")
}
end
diff --git a/app/models/poll/question.rb b/app/models/poll/question.rb
index 97d1d2c60..d7a8d07e5 100644
--- a/app/models/poll/question.rb
+++ b/app/models/poll/question.rb
@@ -27,7 +27,7 @@ class Poll::Question < ApplicationRecord
accepts_nested_attributes_for :question_options, reject_if: :all_blank, allow_destroy: true
accepts_nested_attributes_for :votation_type
- delegate :multiple?, :vote_type, to: :votation_type, allow_nil: true
+ delegate :multiple?, :open?, :vote_type, to: :votation_type, allow_nil: true
scope :sort_for_list, -> { order(Arel.sql("poll_questions.proposal_id IS NULL"), :created_at) }
scope :for_render, -> { includes(:author, :proposal) }
diff --git a/app/models/votation_type.rb b/app/models/votation_type.rb
index 71d61c62d..24326204b 100644
--- a/app/models/votation_type.rb
+++ b/app/models/votation_type.rb
@@ -3,7 +3,7 @@ class VotationType < ApplicationRecord
QUESTIONABLE_TYPES = %w[Poll::Question].freeze
- enum :vote_type, { unique: 0, multiple: 1 }
+ enum :vote_type, { unique: 0, multiple: 1, open: 2 }
validates :questionable, presence: true
validates :questionable_type, inclusion: { in: ->(*) { QUESTIONABLE_TYPES }}
diff --git a/config/locales/en/activerecord.yml b/config/locales/en/activerecord.yml
index 951fa9168..852155ee5 100644
--- a/config/locales/en/activerecord.yml
+++ b/config/locales/en/activerecord.yml
@@ -534,6 +534,7 @@ en:
max_votes: Maximum number of votes
vote_type: Votation type
votation_type/vote_type:
+ open: Open-ended
unique: Unique answer
multiple: Multiple answers
cookies/vendor:
diff --git a/config/locales/en/admin.yml b/config/locales/en/admin.yml
index 80289656d..d6917a5e5 100644
--- a/config/locales/en/admin.yml
+++ b/config/locales/en/admin.yml
@@ -1131,6 +1131,7 @@ en:
title: "Votation type"
unique_description: "It's only possible to answer one time to the question."
multiple_description: "Allows to choose multiple answers. It's possible to set the maximum number of answers."
+ open_description: "Open-ended question that allows users to provide a single answer in their own words."
questions:
index:
create: "Create question"
diff --git a/config/locales/es/activerecord.yml b/config/locales/es/activerecord.yml
index 8148cefbd..61e389ee3 100644
--- a/config/locales/es/activerecord.yml
+++ b/config/locales/es/activerecord.yml
@@ -534,6 +534,7 @@ es:
max_votes: Número máximo de votos
vote_type: Tipo de votación
votation_type/vote_type:
+ open: Respuesta abierta
unique: Respuesta única
multiple: Respuesta múltiple
cookies/vendor:
diff --git a/config/locales/es/admin.yml b/config/locales/es/admin.yml
index 3f7bac10d..8db768fed 100644
--- a/config/locales/es/admin.yml
+++ b/config/locales/es/admin.yml
@@ -1131,6 +1131,7 @@ es:
title: "Tipo de votación"
unique_description: "Solo se puede responder a la pregunta con una única respuesta."
multiple_description: "Permite elegir más de una respuesta. Se puede elegir el número máximo de respuestas."
+ open_description: "Pregunta abierta que permite al usuario dar una única respuesta con sus propias palabras."
questions:
index:
create: "Crear pregunta ciudadana"
diff --git a/spec/factories/polls.rb b/spec/factories/polls.rb
index 358af5e80..155066fd6 100644
--- a/spec/factories/polls.rb
+++ b/spec/factories/polls.rb
@@ -94,6 +94,12 @@ FactoryBot.define do
create(:votation_type_multiple, questionable: question, max_votes: evaluator.max_votes)
end
end
+
+ factory :poll_question_open do
+ after(:create) do |question|
+ create(:votation_type_open, questionable: question)
+ end
+ end
end
factory :poll_question_option, class: "Poll::Question::Option" do
diff --git a/spec/factories/votation_type.rb b/spec/factories/votation_type.rb
index 5a9e77e7d..ef91b836a 100644
--- a/spec/factories/votation_type.rb
+++ b/spec/factories/votation_type.rb
@@ -9,6 +9,10 @@ FactoryBot.define do
max_votes { 3 }
end
+ factory :votation_type_open do
+ vote_type { "open" }
+ end
+
questionable factory: :poll_question
end
end
diff --git a/spec/models/poll/answer_spec.rb b/spec/models/poll/answer_spec.rb
index a70ed3a4d..bc5c50402 100644
--- a/spec/models/poll/answer_spec.rb
+++ b/spec/models/poll/answer_spec.rb
@@ -30,6 +30,13 @@ describe Poll::Answer do
expect(answer).not_to be_valid
end
+ it "is not valid without an answer when question is open-ended" do
+ answer.question = create(:poll_question_open)
+ answer.answer = nil
+
+ expect(answer).not_to be_valid
+ end
+
it "is not valid if there's already an answer to that question" do
author = create(:user)
question = create(:poll_question, :yes_no)
diff --git a/spec/models/votation_type_spec.rb b/spec/models/votation_type_spec.rb
index 4c947ddd6..dac4799cd 100644
--- a/spec/models/votation_type_spec.rb
+++ b/spec/models/votation_type_spec.rb
@@ -1,7 +1,7 @@
require "rails_helper"
describe VotationType do
- let(:vote_types) { %i[votation_type_unique votation_type_multiple] }
+ let(:vote_types) { %i[votation_type_unique votation_type_multiple votation_type_open] }
let(:votation_type) { build(vote_types.sample) }
it "is valid" do
@@ -27,6 +27,10 @@ describe VotationType do
expect(votation_type).to be_valid
+ votation_type.vote_type = "open"
+
+ expect(votation_type).to be_valid
+
votation_type.vote_type = "multiple"
expect(votation_type).not_to be_valid
diff --git a/spec/system/admin/poll/questions_spec.rb b/spec/system/admin/poll/questions_spec.rb
index 2a504de98..3997c3af7 100644
--- a/spec/system/admin/poll/questions_spec.rb
+++ b/spec/system/admin/poll/questions_spec.rb
@@ -81,6 +81,8 @@ describe "Admin poll questions", :admin do
expect(page).to have_content "It's only possible to answer one time to the question."
expect(page).not_to have_content "Allows to choose multiple answers."
expect(page).not_to have_field "Maximum number of votes"
+ expect(page).not_to have_content "Open-ended question that allows users to provide " \
+ "a single answer in their own words."
click_button "Save"
@@ -94,6 +96,8 @@ describe "Admin poll questions", :admin do
expect(page).not_to have_content "It's only possible to answer one time to the question."
expect(page).to have_content "Allows to choose multiple answers."
+ expect(page).not_to have_content "Open-ended question that allows users to provide " \
+ "a single answer in their own words."
fill_in "Maximum number of votes", with: 6
click_button "Save"
@@ -101,6 +105,21 @@ describe "Admin poll questions", :admin do
expect(page).to have_content "Question with multiple answers"
expect(page).to have_content "Multiple answers"
end
+
+ scenario "Open-ended" do
+ fill_in "Question", with: "What do you want?"
+ select "Open-ended", from: "Votation type"
+
+ expect(page).not_to have_content "Allows to choose multiple answers."
+ expect(page).not_to have_field "Maximum number of votes"
+ expect(page).to have_content "Open-ended question that allows users to provide " \
+ "a single answer in their own words."
+
+ click_button "Save"
+
+ expect(page).to have_content "What do you want?"
+ expect(page).to have_content "Open-ended"
+ end
end
end
@@ -143,7 +162,7 @@ describe "Admin poll questions", :admin do
scenario "Update" do
poll = create(:poll, :future)
- question = create(:poll_question, poll: poll)
+ question = create(:poll_question_open, poll: poll)
old_title = question.title
new_title = "Vegetables are great and everyone should have one"
@@ -154,6 +173,12 @@ describe "Admin poll questions", :admin do
end
expect(page).to have_link "Go back", href: admin_poll_path(poll)
+ expect(page).to have_select "Votation type", selected: "Open-ended"
+ expect(page).not_to have_content "Allows to choose multiple answers."
+ expect(page).not_to have_field "Maximum number of votes"
+ expect(page).to have_content "Open-ended question that allows users to provide " \
+ "a single answer in their own words."
+
fill_in "Question", with: new_title
click_button "Save"
From 69eaf66b9310912f255d270e8c92300c60ae52a6 Mon Sep 17 00:00:00 2001
From: taitus
Date: Fri, 10 Oct 2025 15:36:38 +0200
Subject: [PATCH 04/15] Remove redundant `max_votes` validation from
Poll::Answer
Since commit 8deb1964b, the `WebVote` class enforces the maximum vote
validation, making the `max_votes` method in `Poll::Answer` redundant.
---
app/models/poll/answer.rb | 12 ----------
spec/models/poll/answer_spec.rb | 21 ------------------
spec/models/poll/web_vote_spec.rb | 37 +++++++++++++++++++++++++++++++
3 files changed, 37 insertions(+), 33 deletions(-)
diff --git a/app/models/poll/answer.rb b/app/models/poll/answer.rb
index 7663aa42b..664e2f426 100644
--- a/app/models/poll/answer.rb
+++ b/app/models/poll/answer.rb
@@ -9,21 +9,9 @@ class Poll::Answer < ApplicationRecord
validates :author, presence: true
validates :answer, presence: true
validates :option, uniqueness: { scope: :author_id }, allow_nil: true
- validate :max_votes
-
validates :answer, inclusion: { in: ->(poll_answer) { poll_answer.option.possible_answers }},
if: ->(poll_answer) { poll_answer.option.present? }
scope :by_author, ->(author_id) { where(author_id: author_id) }
scope :by_question, ->(question_id) { where(question_id: question_id) }
-
- private
-
- def max_votes
- return if !question || !author || persisted?
-
- if question.answers.by_author(author).count >= question.max_votes
- errors.add(:answer, "Maximum number of votes per user exceeded")
- end
- end
end
diff --git a/spec/models/poll/answer_spec.rb b/spec/models/poll/answer_spec.rb
index bc5c50402..e72fd5da6 100644
--- a/spec/models/poll/answer_spec.rb
+++ b/spec/models/poll/answer_spec.rb
@@ -37,27 +37,6 @@ describe Poll::Answer do
expect(answer).not_to be_valid
end
- it "is not valid if there's already an answer to that question" do
- author = create(:user)
- question = create(:poll_question, :yes_no)
-
- create(:poll_answer, author: author, question: question)
-
- answer = build(:poll_answer, author: author, question: question)
-
- expect(answer).not_to be_valid
- end
-
- it "is not valid when user already reached multiple answers question max votes" do
- author = create(:user)
- question = create(:poll_question_multiple, :abc, max_votes: 2)
- create(:poll_answer, author: author, question: question, answer: "Answer A")
- create(:poll_answer, author: author, question: question, answer: "Answer B")
- answer = build(:poll_answer, author: author, question: question, answer: "Answer C")
-
- expect(answer).not_to be_valid
- end
-
it "is not valid when there are two identical answers" do
author = create(:user)
question = create(:poll_question_multiple, :abc)
diff --git a/spec/models/poll/web_vote_spec.rb b/spec/models/poll/web_vote_spec.rb
index aaa235597..d8c1caa5b 100644
--- a/spec/models/poll/web_vote_spec.rb
+++ b/spec/models/poll/web_vote_spec.rb
@@ -44,6 +44,21 @@ describe Poll::WebVote do
expect(voter.poll_id).to eq answer.poll.id
end
+ it "updates existing multiple options instead of adding new ones" 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, author: user, question: question, option: option_a)
+ create(:poll_answer, author: user, question: question, option: option_b)
+
+ web_vote.update(question.id.to_s => { option_id: [option_c.id.to_s] })
+
+ expect(question.reload.answers.size).to eq 1
+ expect(question.reload.answers.first.option).to eq option_c
+ 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)
@@ -55,6 +70,28 @@ describe Poll::WebVote do
expect(question.answers).to be_blank
end
+ it "does not save the answer if it exceeds the allowed max votes" do
+ question = create(:poll_question_multiple, :abc, poll: poll, max_votes: 2)
+
+ result = web_vote.update(question.id.to_s => { option_id: question.question_options.ids.map(&:to_s) })
+
+ expect(result).to be false
+ expect(poll.voters).to be_blank
+ expect(question.answers).to be_blank
+ end
+
+ it "does not save the answer if unique question receives multiple options" do
+ question = create(:poll_question, :yes_no, poll: poll)
+
+ result = web_vote.update(
+ question.id.to_s => { option_id: question.question_options.ids.map(&:to_s) }
+ )
+
+ expect(result).to be false
+ 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({})
From d3f32978c850b7f6035c9bf3fbb850ef6ffd4c9d Mon Sep 17 00:00:00 2001
From: taitus
Date: Mon, 13 Oct 2025 12:36:35 +0200
Subject: [PATCH 05/15] Hide "Maximum number of votes" message for unique and
open-ended questions
The "Maximum number of votes" text in poll question show was unnecessary.
It appeared for both unique and open-ended questions, but it only makes
sense for questions that allow multiple answers.
---
app/views/admin/poll/questions/show.html.erb | 2 +-
spec/system/admin/poll/questions_spec.rb | 3 +++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/app/views/admin/poll/questions/show.html.erb b/app/views/admin/poll/questions/show.html.erb
index 35b09b9f9..4a6a98378 100644
--- a/app/views/admin/poll/questions/show.html.erb
+++ b/app/views/admin/poll/questions/show.html.erb
@@ -35,7 +35,7 @@
<%= VotationType.human_attribute_name("vote_type.#{@question.vote_type}") %>
- <% if @question.max_votes.present? %>
+ <% if @question.multiple? %>
<%= VotationType.human_attribute_name("max_votes") %>
diff --git a/spec/system/admin/poll/questions_spec.rb b/spec/system/admin/poll/questions_spec.rb
index 3997c3af7..1bb7a55fb 100644
--- a/spec/system/admin/poll/questions_spec.rb
+++ b/spec/system/admin/poll/questions_spec.rb
@@ -88,6 +88,7 @@ describe "Admin poll questions", :admin do
expect(page).to have_content "Question with unique answer"
expect(page).to have_content "Unique answer"
+ expect(page).not_to have_content "Maximum number of votes"
end
scenario "Multiple" do
@@ -104,6 +105,7 @@ describe "Admin poll questions", :admin do
expect(page).to have_content "Question with multiple answers"
expect(page).to have_content "Multiple answers"
+ expect(page).to have_text "Maximum number of votes 6", normalize_ws: true
end
scenario "Open-ended" do
@@ -119,6 +121,7 @@ describe "Admin poll questions", :admin do
expect(page).to have_content "What do you want?"
expect(page).to have_content "Open-ended"
+ expect(page).not_to have_content "Maximum number of votes"
end
end
end
From b3f8ba819b3396609630a1ad35d3d6e1741bc3a9 Mon Sep 17 00:00:00 2001
From: taitus
Date: Wed, 6 Aug 2025 11:54:30 +0200
Subject: [PATCH 06/15] Adapt 'show' view for open questions without options
- Prevent creating options for open questions
- Skip rendering the options table when none exist
---
app/models/abilities/administrator.rb | 2 +-
app/models/poll/question.rb | 4 +
app/models/votation_type.rb | 4 +
app/views/admin/poll/questions/show.html.erb | 108 ++++++++++---------
spec/models/abilities/administrator_spec.rb | 5 +
spec/system/admin/poll/questions_spec.rb | 6 ++
6 files changed, 75 insertions(+), 54 deletions(-)
diff --git a/app/models/abilities/administrator.rb b/app/models/abilities/administrator.rb
index 9e9c123ce..857280fe0 100644
--- a/app/models/abilities/administrator.rb
+++ b/app/models/abilities/administrator.rb
@@ -101,7 +101,7 @@ module Abilities
end
can [:read, :order_options], Poll::Question::Option
can [:create, :update, :destroy], Poll::Question::Option do |option|
- can?(:update, option.question)
+ can?(:update, option.question) && option.question.accepts_options?
end
can :read, Poll::Question::Option::Video
can [:create, :update, :destroy], Poll::Question::Option::Video do |video|
diff --git a/app/models/poll/question.rb b/app/models/poll/question.rb
index d7a8d07e5..9e393394b 100644
--- a/app/models/poll/question.rb
+++ b/app/models/poll/question.rb
@@ -61,6 +61,10 @@ class Poll::Question < ApplicationRecord
votation_type.nil? || votation_type.unique?
end
+ def accepts_options?
+ votation_type.nil? || votation_type.accepts_options?
+ end
+
def max_votes
if multiple?
votation_type.max_votes
diff --git a/app/models/votation_type.rb b/app/models/votation_type.rb
index 24326204b..79c974681 100644
--- a/app/models/votation_type.rb
+++ b/app/models/votation_type.rb
@@ -9,6 +9,10 @@ class VotationType < ApplicationRecord
validates :questionable_type, inclusion: { in: ->(*) { QUESTIONABLE_TYPES }}
validates :max_votes, presence: true, if: :max_votes_required?
+ def accepts_options?
+ !open?
+ end
+
private
def max_votes_required?
diff --git a/app/views/admin/poll/questions/show.html.erb b/app/views/admin/poll/questions/show.html.erb
index 4a6a98378..d9c83803d 100644
--- a/app/views/admin/poll/questions/show.html.erb
+++ b/app/views/admin/poll/questions/show.html.erb
@@ -46,57 +46,59 @@
-
- <% if can?(:create, Poll::Question::Option.new(question: @question)) %>
- <%= link_to t("admin.questions.show.add_answer"), new_admin_question_option_path(@question),
- class: "button float-right" %>
- <% else %>
-
- <%= t("admin.questions.no_edit") %>
-
- <% end %>
-
-
-
- <%= t("admin.questions.show.valid_answers") %>
-
-
- <%= t("admin.questions.show.answers.title") %>
- <%= t("admin.questions.show.answers.description") %>
- <%= t("admin.questions.show.answers.images") %>
- <%= t("admin.questions.show.answers.documents") %>
- <%= t("admin.questions.show.answers.videos") %>
- <%= t("admin.actions.actions") %>
-
-
-
-
- <% @question.question_options.each do |option| %>
-
- <%= option.title %>
- <%= wysiwyg(option.description) %>
-
- (<%= option.images.count %>)
-
- <%= link_to t("admin.questions.show.answers.images_list"),
- admin_option_images_path(option) %>
-
-
- (<%= option.documents.count rescue 0 %>)
-
- <%= link_to t("admin.questions.show.answers.documents_list"),
- admin_option_documents_path(option) %>
-
-
- (<%= option.videos.count %>)
-
- <%= link_to t("admin.questions.show.answers.video_list"),
- admin_option_videos_path(option) %>
-
-
- <%= render Admin::Poll::Questions::Options::TableActionsComponent.new(option) %>
-
-
+<% if @question.accepts_options? %>
+
+ <% if can?(:create, Poll::Question::Option.new(question: @question)) %>
+ <%= link_to t("admin.questions.show.add_answer"), new_admin_question_option_path(@question),
+ class: "button float-right" %>
+ <% else %>
+
+ <%= t("admin.questions.no_edit") %>
+
<% end %>
-
-
+
+
+
+ <%= t("admin.questions.show.valid_answers") %>
+
+
+ <%= t("admin.questions.show.answers.title") %>
+ <%= t("admin.questions.show.answers.description") %>
+ <%= t("admin.questions.show.answers.images") %>
+ <%= t("admin.questions.show.answers.documents") %>
+ <%= t("admin.questions.show.answers.videos") %>
+ <%= t("admin.actions.actions") %>
+
+
+
+
+ <% @question.question_options.each do |option| %>
+
+ <%= option.title %>
+ <%= wysiwyg(option.description) %>
+
+ (<%= option.images.count %>)
+
+ <%= link_to t("admin.questions.show.answers.images_list"),
+ admin_option_images_path(option) %>
+
+
+ (<%= option.documents.count rescue 0 %>)
+
+ <%= link_to t("admin.questions.show.answers.documents_list"),
+ admin_option_documents_path(option) %>
+
+
+ (<%= option.videos.count %>)
+
+ <%= link_to t("admin.questions.show.answers.video_list"),
+ admin_option_videos_path(option) %>
+
+
+ <%= render Admin::Poll::Questions::Options::TableActionsComponent.new(option) %>
+
+
+ <% end %>
+
+
+<% end %>
diff --git a/spec/models/abilities/administrator_spec.rb b/spec/models/abilities/administrator_spec.rb
index 24b10a9cc..e6678ee21 100644
--- a/spec/models/abilities/administrator_spec.rb
+++ b/spec/models/abilities/administrator_spec.rb
@@ -20,6 +20,8 @@ describe Abilities::Administrator do
let(:future_poll) { create(:poll, :future) }
let(:current_poll_question) { create(:poll_question) }
let(:future_poll_question) { create(:poll_question, poll: future_poll) }
+ let(:future_poll_question_open) { create(:poll_question_open, poll: future_poll) }
+ let(:future_poll_question_option_open) { future_poll_question_open.question_options.new }
let(:current_poll_question_option) { create(:poll_question_option) }
let(:future_poll_question_option) { create(:poll_question_option, poll: future_poll) }
let(:current_poll_option_video) { create(:poll_option_video, option: current_poll_question_option) }
@@ -143,6 +145,9 @@ describe Abilities::Administrator do
it { should_not be_able_to(:create, current_poll_question_option) }
it { should_not be_able_to(:update, current_poll_question_option) }
it { should_not be_able_to(:destroy, current_poll_question_option) }
+ it { should_not be_able_to(:create, future_poll_question_option_open) }
+ it { should_not be_able_to(:update, future_poll_question_option_open) }
+ it { should_not be_able_to(:destroy, future_poll_question_option_open) }
it { should be_able_to(:create, future_poll_option_video) }
it { should be_able_to(:update, future_poll_option_video) }
diff --git a/spec/system/admin/poll/questions_spec.rb b/spec/system/admin/poll/questions_spec.rb
index 1bb7a55fb..ac913fe07 100644
--- a/spec/system/admin/poll/questions_spec.rb
+++ b/spec/system/admin/poll/questions_spec.rb
@@ -89,6 +89,8 @@ describe "Admin poll questions", :admin do
expect(page).to have_content "Question with unique answer"
expect(page).to have_content "Unique answer"
expect(page).not_to have_content "Maximum number of votes"
+ expect(page).to have_link "Add answer"
+ expect(page).to have_table "Valid answers"
end
scenario "Multiple" do
@@ -106,6 +108,8 @@ describe "Admin poll questions", :admin do
expect(page).to have_content "Question with multiple answers"
expect(page).to have_content "Multiple answers"
expect(page).to have_text "Maximum number of votes 6", normalize_ws: true
+ expect(page).to have_link "Add answer"
+ expect(page).to have_table "Valid answers"
end
scenario "Open-ended" do
@@ -122,6 +126,8 @@ describe "Admin poll questions", :admin do
expect(page).to have_content "What do you want?"
expect(page).to have_content "Open-ended"
expect(page).not_to have_content "Maximum number of votes"
+ expect(page).not_to have_link "Add answer"
+ expect(page).not_to have_table "Valid answers"
end
end
end
From 9ff167d040393d73c86445400c137dff4b18e8e2 Mon Sep 17 00:00:00 2001
From: taitus
Date: Mon, 13 Oct 2025 13:41:53 +0200
Subject: [PATCH 07/15] Use option instead of answer when sampling question
options
We were still assigning answer: question.question_options.sample.title,
which made sense before we introduced the option association.
---
db/dev_seeds/polls.rb | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/db/dev_seeds/polls.rb b/db/dev_seeds/polls.rb
index 78403006e..1d5e25077 100644
--- a/db/dev_seeds/polls.rb
+++ b/db/dev_seeds/polls.rb
@@ -158,9 +158,11 @@ section "Creating Poll Voters" do
poll.questions.each do |question|
next unless [true, false].sample
+ option = question.question_options.sample
Poll::Answer.create!(question_id: question.id,
author: user,
- answer: question.question_options.sample.title)
+ option: option,
+ answer: option.title)
end
end
From b4b00487cc583be3a5d1c27c03e4ff2506473146 Mon Sep 17 00:00:00 2001
From: taitus
Date: Wed, 6 Aug 2025 12:13:13 +0200
Subject: [PATCH 08/15] Add validations for changing votation type
---
app/models/votation_type.rb | 8 +++++
config/locales/en/activerecord.yml | 4 +++
config/locales/es/activerecord.yml | 4 +++
db/dev_seeds/polls.rb | 53 ++++++++++++++++--------------
spec/models/votation_type_spec.rb | 22 +++++++++++++
5 files changed, 66 insertions(+), 25 deletions(-)
diff --git a/app/models/votation_type.rb b/app/models/votation_type.rb
index 79c974681..af2758527 100644
--- a/app/models/votation_type.rb
+++ b/app/models/votation_type.rb
@@ -1,6 +1,8 @@
class VotationType < ApplicationRecord
belongs_to :questionable, polymorphic: true
+ validate :cannot_be_open_ended_if_question_has_options
+
QUESTIONABLE_TYPES = %w[Poll::Question].freeze
enum :vote_type, { unique: 0, multiple: 1, open: 2 }
@@ -18,4 +20,10 @@ class VotationType < ApplicationRecord
def max_votes_required?
multiple?
end
+
+ def cannot_be_open_ended_if_question_has_options
+ if questionable&.question_options&.any? && !accepts_options?
+ errors.add(:vote_type, :cannot_change_to_open_ended)
+ end
+ end
end
diff --git a/config/locales/en/activerecord.yml b/config/locales/en/activerecord.yml
index 852155ee5..ec1402b59 100644
--- a/config/locales/en/activerecord.yml
+++ b/config/locales/en/activerecord.yml
@@ -629,6 +629,10 @@ en:
attributes:
code:
invalid: "must start with the same code as its target followed by a dot and end with a number"
+ votation_type:
+ attributes:
+ vote_type:
+ cannot_change_to_open_ended: "can't change to open-ended type because you've already defined possible valid answers for this question"
messages:
translations_too_short: Is mandatory to provide one translation at least
record_invalid: "Validation failed: %{errors}"
diff --git a/config/locales/es/activerecord.yml b/config/locales/es/activerecord.yml
index 61e389ee3..44e866faa 100644
--- a/config/locales/es/activerecord.yml
+++ b/config/locales/es/activerecord.yml
@@ -629,6 +629,10 @@ es:
attributes:
code:
invalid: "debe empezar con el código de su meta seguido de un punto y terminar con un número"
+ votation_type:
+ attributes:
+ vote_type:
+ cannot_change_to_open_ended: "no se puede cambiar a respuesta abierta el tipo de votación porque ya has definido posibles respuestas válidas para esta pregunta"
messages:
translations_too_short: El obligatorio proporcionar una traducción como mínimo
record_invalid: "Error de validación: %{errors}"
diff --git a/db/dev_seeds/polls.rb b/db/dev_seeds/polls.rb
index 1d5e25077..8e5a952c9 100644
--- a/db/dev_seeds/polls.rb
+++ b/db/dev_seeds/polls.rb
@@ -52,6 +52,7 @@ end
section "Creating Poll Questions & Options" do
Poll.find_each do |poll|
(3..5).to_a.sample.times do
+ vote_type = VotationType.vote_types.keys.sample
question_title = Faker::Lorem.sentence(word_count: 3).truncate(60) + "?"
question = Poll::Question.new(author: User.sample,
title: question_title,
@@ -62,33 +63,29 @@ section "Creating Poll Questions & Options" do
end
end
question.save!
- Faker::Lorem.words(number: (2..4).to_a.sample).each_with_index do |title, index|
- description = "#{Faker::Lorem.paragraphs.join("
")}
"
- option = Poll::Question::Option.new(question: question,
- title: title.capitalize,
- description: description,
- given_order: index + 1)
- Setting.enabled_locales.map do |locale|
- Globalize.with_locale(locale) do
- option.title = "#{title} (#{locale})"
- option.description = "#{description} (#{locale})"
+
+ question.create_votation_type!(vote_type: vote_type, max_votes: (3 if vote_type == "multiple"))
+
+ if question.accepts_options?
+ Faker::Lorem.words(number: (2..4).to_a.sample).each_with_index do |title, index|
+ description = "#{Faker::Lorem.paragraphs.join("
")}
"
+ option = Poll::Question::Option.new(question: question,
+ title: title.capitalize,
+ description: description,
+ given_order: index + 1)
+ Setting.enabled_locales.map do |locale|
+ Globalize.with_locale(locale) do
+ option.title = "#{title} (#{locale})"
+ option.description = "#{description} (#{locale})"
+ end
end
+ option.save!
end
- option.save!
end
end
end
end
-section "Creating Poll Votation types" do
- poll = Poll.first
-
- poll.questions.each do |question|
- vote_type = VotationType.vote_types.keys.sample
- question.create_votation_type!(vote_type: vote_type, max_votes: (3 unless vote_type == "unique"))
- end
-end
-
section "Creating Poll Booths & BoothAssignments" do
20.times do |i|
Poll::Booth.create(name: "Booth #{i}",
@@ -158,11 +155,16 @@ section "Creating Poll Voters" do
poll.questions.each do |question|
next unless [true, false].sample
- option = question.question_options.sample
- Poll::Answer.create!(question_id: question.id,
- author: user,
- option: option,
- answer: option.title)
+ if question.accepts_options?
+ option = question.question_options.sample
+ Poll::Answer.create!(question_id: question.id,
+ author: user,
+ option: option,
+ answer: option.title)
+ else
+ text = Faker::Lorem.sentence(word_count: (6..14).to_a.sample)
+ Poll::Answer.create!(question_id: question.id, author: user, answer: text)
+ end
end
end
@@ -254,6 +256,7 @@ section "Creating Poll Questions from Proposals" do
end
option.save!
end
+ question.create_votation_type!(vote_type: "unique")
end
end
diff --git a/spec/models/votation_type_spec.rb b/spec/models/votation_type_spec.rb
index dac4799cd..b31090a6f 100644
--- a/spec/models/votation_type_spec.rb
+++ b/spec/models/votation_type_spec.rb
@@ -36,4 +36,26 @@ describe VotationType do
expect(votation_type).not_to be_valid
expect(votation_type.errors[:max_votes]).to include "can't be blank"
end
+
+ describe "#cannot_be_open_ended_if_question_has_options" do
+ it "allows changing to open-ended when the question has no options" do
+ votation_type = create(:votation_type_unique)
+
+ votation_type.vote_type = :open
+
+ expect(votation_type).to be_valid
+ end
+
+ it "blocks changing to open-ended when the question has options" do
+ votation_type = create(:votation_type_unique)
+ create(:poll_question_option, question: votation_type.questionable)
+
+ votation_type.vote_type = :open
+
+ expect(votation_type).not_to be_valid
+ error = votation_type.errors[:vote_type].first
+ expect(error).to eq "can't change to open-ended type " \
+ "because you've already defined possible valid answers for this question"
+ end
+ end
end
From 62e1c13e7e00ea4db1d344725393c00ca1e39f69 Mon Sep 17 00:00:00 2001
From: taitus
Date: Mon, 29 Sep 2025 17:14:37 +0200
Subject: [PATCH 09/15] Use option instead of answer text to find multiple
answers
---
app/models/poll/question.rb | 2 +-
spec/models/poll/question_spec.rb | 64 +++++++++++++++++++++++++++++++
2 files changed, 65 insertions(+), 1 deletion(-)
diff --git a/app/models/poll/question.rb b/app/models/poll/question.rb
index 9e393394b..dcbe1c613 100644
--- a/app/models/poll/question.rb
+++ b/app/models/poll/question.rb
@@ -89,7 +89,7 @@ class Poll::Question < ApplicationRecord
when "unique", nil
{ author: user }
when "multiple"
- { author: user, answer: option.title }
+ { author: user, option: option }
end
end
end
diff --git a/spec/models/poll/question_spec.rb b/spec/models/poll/question_spec.rb
index ddd57aa58..320debbdb 100644
--- a/spec/models/poll/question_spec.rb
+++ b/spec/models/poll/question_spec.rb
@@ -86,4 +86,68 @@ RSpec.describe Poll::Question do
expect(question.options_total_votes).to eq 3
end
end
+
+ describe "#find_or_initialize_user_answer" do
+ let(:user) { create(:user) }
+ let(:other_user) { create(:user) }
+
+ context "unique question" do
+ let(:question) { create(:poll_question_unique, :abc) }
+ let(:answer_a) { question.question_options.find_by(title: "Answer A") }
+ let(:answer_b) { question.question_options.find_by(title: "Answer B") }
+
+ it "finds the existing answer for the same user" do
+ existing_answer = create(:poll_answer, question: question, author: user, option: answer_a)
+ create(:poll_answer, question: question, author: other_user, option: answer_b)
+
+ answer = question.find_or_initialize_user_answer(user, answer_b.id)
+
+ expect(answer).to eq existing_answer
+ expect(answer.author).to eq user
+ expect(answer.option).to eq answer_b
+ expect(answer.answer).to eq "Answer B"
+ end
+
+ it "initializes a new answer when only another user has answered" do
+ create(:poll_answer, question: question, author: other_user, option: answer_a)
+
+ answer = question.find_or_initialize_user_answer(user, answer_a.id)
+
+ expect(answer).to be_new_record
+ expect(answer.author).to eq user
+ expect(answer.option).to eq answer_a
+ expect(answer.answer).to eq "Answer A"
+ end
+ end
+
+ context "multiple question" do
+ let(:question) { create(:poll_question_multiple, :abc, max_votes: 3) }
+ let(:answer_a) { question.question_options.find_by(title: "Answer A") }
+ let(:answer_b) { question.question_options.find_by(title: "Answer B") }
+
+ it "finds the existing answer for the same user and option" do
+ existing_answer = create(:poll_answer, question: question, author: user, option: answer_a)
+ create(:poll_answer, question: question, author: other_user, option: answer_a)
+
+ answer = question.find_or_initialize_user_answer(user, answer_a.id)
+
+ expect(answer).to eq existing_answer
+ expect(answer.author).to eq user
+ expect(answer.option).to eq answer_a
+ expect(answer.answer).to eq "Answer A"
+ end
+
+ it "initializes a new answer when selecting a different option" do
+ create(:poll_answer, question: question, author: user, option: answer_a)
+ create(:poll_answer, question: question, author: other_user, option: answer_b)
+
+ answer = question.find_or_initialize_user_answer(user, answer_b.id)
+
+ expect(answer).to be_new_record
+ expect(answer.author).to eq user
+ expect(answer.option).to eq answer_b
+ expect(answer.answer).to eq "Answer B"
+ end
+ end
+ end
end
From 83b206f0b744750973dc85108447912e80e1ed5f Mon Sep 17 00:00:00 2001
From: taitus
Date: Fri, 8 Aug 2025 15:30:46 +0200
Subject: [PATCH 10/15] Enable voting for open-ended questions in public
section
---
app/assets/stylesheets/polls/form.scss | 47 ++++++++++-------
.../questions/question_component.html.erb | 50 +++++++++++--------
.../polls/questions/question_component.rb | 4 ++
app/models/poll/question.rb | 24 +++++----
app/models/poll/web_vote.rb | 13 ++++-
.../questions/question_component_spec.rb | 21 ++++++++
spec/models/poll/question_spec.rb | 46 +++++++++++++++--
spec/models/poll/web_vote_spec.rb | 42 ++++++++++++++++
spec/system/polls/votation_types_spec.rb | 11 +++-
9 files changed, 201 insertions(+), 57 deletions(-)
diff --git a/app/assets/stylesheets/polls/form.scss b/app/assets/stylesheets/polls/form.scss
index f6129dbf2..eb46dde94 100644
--- a/app/assets/stylesheets/polls/form.scss
+++ b/app/assets/stylesheets/polls/form.scss
@@ -1,33 +1,42 @@
.poll-form {
- fieldset {
+ fieldset,
+ .poll-question-open-ended {
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;
+ margin-top: calc($line-height / 2);
}
}
+ fieldset {
+ legend {
+ @include header-font-size(h3);
+ float: $global-left;
+ margin-bottom: 0;
- label {
- @include radio-or-checkbox-and-label-alignment;
- font-weight: normal;
+ + * {
+ clear: $global-left;
+ }
+ }
- &:first-of-type::before {
- content: "\A";
- margin-top: calc($line-height / 2);
- white-space: pre;
+ 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;
+ }
+ }
+ }
+
+ .poll-question-open-ended {
+ label {
+ @include header-font-size(h3);
+ line-height: 1.5;
}
}
diff --git a/app/components/polls/questions/question_component.html.erb b/app/components/polls/questions/question_component.html.erb
index 075fc4dc1..614b014d9 100644
--- a/app/components/polls/questions/question_component.html.erb
+++ b/app/components/polls/questions/question_component.html.erb
@@ -1,23 +1,31 @@
->
- <%= question.title %>
-
- <% if multiple_choice? %>
- <%= multiple_choice_help_text %>
-
- <% question.question_options.each do |option| %>
- <%= multiple_choice_field(option) %>
+<% if question.open? %>
+
+ <%= fields_for "web_vote[#{question.id}]" do |f| %>
+ <%= f.text_area :answer, label: question.title, value: existing_answer, rows: 3 %>
<% end %>
- <% else %>
- <% question.question_options.each do |option| %>
- <%= single_choice_field(option) %>
- <% end %>
- <% end %>
+
+<% else %>
+ >
+ <%= question.title %>
- <% if question.options_with_read_more? %>
-
-
<%= t("poll_questions.read_more_about") %>
-
<%= options_read_more_links %>
-
- <% end %>
- <%= form.error_for(:"question_#{question.id}") %>
-
+ <% if multiple_choice? %>
+ <%= multiple_choice_help_text %>
+
+ <% question.question_options.each do |option| %>
+ <%= multiple_choice_field(option) %>
+ <% end %>
+ <% else %>
+ <% question.question_options.each do |option| %>
+ <%= single_choice_field(option) %>
+ <% end %>
+ <% end %>
+
+ <% if question.options_with_read_more? %>
+
+
<%= t("poll_questions.read_more_about") %>
+
<%= options_read_more_links %>
+
+ <% end %>
+ <%= form.error_for(:"question_#{question.id}") %>
+
+<% end %>
diff --git a/app/components/polls/questions/question_component.rb b/app/components/polls/questions/question_component.rb
index cd74b39fc..c0cc2500a 100644
--- a/app/components/polls/questions/question_component.rb
+++ b/app/components/polls/questions/question_component.rb
@@ -33,6 +33,10 @@ class Polls::Questions::QuestionComponent < ApplicationComponent
end, ", ")
end
+ def existing_answer
+ form.object.answers[question.id]&.first&.answer
+ end
+
def multiple_choice?
question.multiple?
end
diff --git a/app/models/poll/question.rb b/app/models/poll/question.rb
index dcbe1c613..f40d47d33 100644
--- a/app/models/poll/question.rb
+++ b/app/models/poll/question.rb
@@ -73,23 +73,27 @@ class Poll::Question < ApplicationRecord
end
end
- def find_or_initialize_user_answer(user, option_id)
- option = question_options.find(option_id)
+ def find_or_initialize_user_answer(user, option_id: nil, answer_text: nil)
+ answer = answers.find_or_initialize_by(find_by_attributes(user, option_id))
+
+ if accepts_options?
+ option = question_options.find(option_id)
+ answer.option = option
+ answer.answer = option.title
+ else
+ answer.answer = answer_text
+ end
- answer = answers.find_or_initialize_by(find_by_attributes(user, option))
- answer.option = option
- answer.answer = option.title
answer
end
private
- def find_by_attributes(user, option)
- case vote_type
- when "unique", nil
+ def find_by_attributes(user, option_id)
+ if multiple?
+ { author: user, option_id: option_id }
+ else
{ author: user }
- when "multiple"
- { author: user, option: option }
end
end
end
diff --git a/app/models/poll/web_vote.rb b/app/models/poll/web_vote.rb
index 9ceda20e5..f3f26f4a3 100644
--- a/app/models/poll/web_vote.rb
+++ b/app/models/poll/web_vote.rb
@@ -63,8 +63,17 @@ class Poll::WebVote
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)
+ if question.open?
+ answer_text = question_params[:answer].to_s.strip
+ if answer_text.present?
+ [question.find_or_initialize_user_answer(user, answer_text: answer_text)]
+ else
+ []
+ end
+ else
+ Array(question_params[:option_id]).map do |option_id|
+ question.find_or_initialize_user_answer(user, option_id: option_id)
+ end
end
end
diff --git a/spec/components/polls/questions/question_component_spec.rb b/spec/components/polls/questions/question_component_spec.rb
index 1b0e7c219..61deadffb 100644
--- a/spec/components/polls/questions/question_component_spec.rb
+++ b/spec/components/polls/questions/question_component_spec.rb
@@ -69,5 +69,26 @@ describe Polls::Questions::QuestionComponent do
expect(page).to have_field "Yes", type: :radio, checked: true
expect(page).to have_field "No", type: :radio, checked: false
end
+
+ context "Open-ended question" do
+ let(:question) { create(:poll_question_open, poll: poll, title: "What do you want?") }
+ before { create(:poll_answer, author: user, question: question, answer: "I don't know") }
+
+ it "renders text area with persisted answer" do
+ render_inline Polls::Questions::QuestionComponent.new(question, form: form)
+
+ expect(page).to have_field "What do you want?", type: :textarea, with: "I don't know"
+ end
+
+ it "renders unsaved form text over the persisted value" do
+ web_vote.answers[question.id] = [
+ build(:poll_answer, question: question, author: user, answer: "Typed (unsaved)")
+ ]
+
+ render_inline Polls::Questions::QuestionComponent.new(question, form: form)
+
+ expect(page).to have_field "What do you want?", type: :textarea, with: "Typed (unsaved)"
+ end
+ end
end
end
diff --git a/spec/models/poll/question_spec.rb b/spec/models/poll/question_spec.rb
index 320debbdb..f0f5e0882 100644
--- a/spec/models/poll/question_spec.rb
+++ b/spec/models/poll/question_spec.rb
@@ -100,7 +100,7 @@ RSpec.describe Poll::Question do
existing_answer = create(:poll_answer, question: question, author: user, option: answer_a)
create(:poll_answer, question: question, author: other_user, option: answer_b)
- answer = question.find_or_initialize_user_answer(user, answer_b.id)
+ answer = question.find_or_initialize_user_answer(user, option_id: answer_b.id)
expect(answer).to eq existing_answer
expect(answer.author).to eq user
@@ -111,13 +111,25 @@ RSpec.describe Poll::Question do
it "initializes a new answer when only another user has answered" do
create(:poll_answer, question: question, author: other_user, option: answer_a)
- answer = question.find_or_initialize_user_answer(user, answer_a.id)
+ answer = question.find_or_initialize_user_answer(user, option_id: answer_a.id)
expect(answer).to be_new_record
expect(answer.author).to eq user
expect(answer.option).to eq answer_a
expect(answer.answer).to eq "Answer A"
end
+
+ it "raises when option_id is invalid" do
+ expect do
+ question.find_or_initialize_user_answer(user, option_id: 999999)
+ end.to raise_error(ActiveRecord::RecordNotFound)
+ end
+
+ it "raises when option_id is nil" do
+ expect do
+ question.find_or_initialize_user_answer(user, answer_text: "ignored")
+ end.to raise_error(ActiveRecord::RecordNotFound)
+ end
end
context "multiple question" do
@@ -129,7 +141,7 @@ RSpec.describe Poll::Question do
existing_answer = create(:poll_answer, question: question, author: user, option: answer_a)
create(:poll_answer, question: question, author: other_user, option: answer_a)
- answer = question.find_or_initialize_user_answer(user, answer_a.id)
+ answer = question.find_or_initialize_user_answer(user, option_id: answer_a.id)
expect(answer).to eq existing_answer
expect(answer.author).to eq user
@@ -141,7 +153,7 @@ RSpec.describe Poll::Question do
create(:poll_answer, question: question, author: user, option: answer_a)
create(:poll_answer, question: question, author: other_user, option: answer_b)
- answer = question.find_or_initialize_user_answer(user, answer_b.id)
+ answer = question.find_or_initialize_user_answer(user, option_id: answer_b.id)
expect(answer).to be_new_record
expect(answer.author).to eq user
@@ -149,5 +161,31 @@ RSpec.describe Poll::Question do
expect(answer.answer).to eq "Answer B"
end
end
+
+ context "Open-ended question" do
+ let(:question) { create(:poll_question_open) }
+
+ it "ignores invalid option_id and uses answer_text" do
+ answer = question.find_or_initialize_user_answer(user, option_id: 999999, answer_text: "Hi")
+ expect(answer.option).to be nil
+ expect(answer.answer).to eq "Hi"
+ end
+
+ it "ignores option_id when nil and assigns answer with option set to nil" do
+ answer = question.find_or_initialize_user_answer(user, answer_text: "Hi")
+
+ expect(answer.option).to be nil
+ expect(answer.answer).to eq "Hi"
+ end
+
+ it "reuses the existing poll answer for the user and updates answer" do
+ existing = create(:poll_answer, question: question, author: user, answer: "Before")
+
+ answer = question.find_or_initialize_user_answer(user, answer_text: "After")
+ expect(answer).to eq existing
+ expect(answer.author).to eq user
+ expect(answer.answer).to eq "After"
+ end
+ end
end
end
diff --git a/spec/models/poll/web_vote_spec.rb b/spec/models/poll/web_vote_spec.rb
index d8c1caa5b..42ef8af86 100644
--- a/spec/models/poll/web_vote_spec.rb
+++ b/spec/models/poll/web_vote_spec.rb
@@ -156,5 +156,47 @@ describe Poll::WebVote do
expect(Poll::Answer.count).to be 2
end
end
+
+ context "Open-ended questions" do
+ let!(:open_ended_question) { create(:poll_question_open, poll: poll) }
+
+ it "creates one answer when text is present" do
+ web_vote.update(open_ended_question.id.to_s => { answer: " Hi " })
+
+ expect(poll.reload.voters.size).to eq 1
+ open_answer = open_ended_question.reload.answers.find_by(author: user)
+
+ expect(open_answer.answer).to eq "Hi"
+ expect(open_answer.option_id).to be nil
+ end
+
+ it "does not create an answer but create voters when text is blank or only spaces" do
+ web_vote.update(open_ended_question.id.to_s => { answer: " " })
+
+ expect(poll.reload.voters.size).to eq 1
+ expect(open_ended_question.reload.answers.where(author: user)).to be_empty
+ end
+
+ it "deletes existing answer but keeps voters when leaving open-ended blank" do
+ create(:poll_answer, question: open_ended_question, author: user, answer: "Old answer")
+
+ web_vote.update(open_ended_question.id.to_s => { answer: " " })
+
+ expect(poll.reload.voters.size).to eq 1
+ expect(open_ended_question.reload.answers.where(author: user)).to be_empty
+ end
+
+ it "updates existing open answer without creating duplicates" do
+ existing = create(:poll_answer, question: open_ended_question, author: user, answer: "Old text")
+
+ web_vote.update(open_ended_question.id.to_s => { answer: " New text " })
+
+ updated = open_ended_question.reload.answers.find_by(author: user)
+ expect(updated.id).to eq existing.id
+ expect(updated.answer).to eq "New text"
+ expect(updated.option_id).to be nil
+ expect(poll.reload.voters.size).to eq 1
+ end
+ end
end
end
diff --git a/spec/system/polls/votation_types_spec.rb b/spec/system/polls/votation_types_spec.rb
index 9ccbcbd97..444c785de 100644
--- a/spec/system/polls/votation_types_spec.rb
+++ b/spec/system/polls/votation_types_spec.rb
@@ -8,9 +8,10 @@ describe "Poll Votation Type" do
login_as(author)
end
- scenario "Unique and multiple answers" do
+ scenario "Unique, multiple and open answers" do
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?")
+ create(:poll_question_open, poll: poll, title: "What do you think?")
visit poll_path(poll)
@@ -21,6 +22,10 @@ describe "Poll Votation Type" do
check "Answer C"
end
+ within(".poll-question-open-ended") do
+ fill_in "What do you think?", with: "I believe it's great"
+ end
+
click_button "Vote"
expect(page).to have_content "Thank you for voting!"
@@ -39,6 +44,10 @@ describe "Poll Votation Type" do
expect(page).to have_field "Answer E", type: :checkbox, checked: false
end
+ within(".poll-question-open-ended") do
+ expect(page).to have_field "What do you think?", with: "I believe it's great"
+ end
+
expect(page).to have_button "Vote"
end
From 5944bb85c532d1eed69a820cd9a7479e95e0508f Mon Sep 17 00:00:00 2001
From: taitus
Date: Wed, 20 Aug 2025 09:37:45 +0200
Subject: [PATCH 11/15] Use a loop instead of with_collection to render
questions
This is what we usually do in components.
---
app/components/polls/results/question_component.rb | 2 +-
app/components/polls/results_component.html.erb | 4 +++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/app/components/polls/results/question_component.rb b/app/components/polls/results/question_component.rb
index ce41ca1fc..ba84d2108 100644
--- a/app/components/polls/results/question_component.rb
+++ b/app/components/polls/results/question_component.rb
@@ -1,7 +1,7 @@
class Polls::Results::QuestionComponent < ApplicationComponent
attr_reader :question
- def initialize(question:)
+ def initialize(question)
@question = question
end
diff --git a/app/components/polls/results_component.html.erb b/app/components/polls/results_component.html.erb
index 28e33ad82..cb43c0596 100644
--- a/app/components/polls/results_component.html.erb
+++ b/app/components/polls/results_component.html.erb
@@ -16,7 +16,9 @@
- <%= render Polls::Results::QuestionComponent.with_collection(poll.questions) %>
+ <% poll.questions.each do |question| %>
+ <%= render Polls::Results::QuestionComponent.new(question) %>
+ <% end %>
From 5a69ffc61917f9f9b8fbee91259a847a390c4a13 Mon Sep 17 00:00:00 2001
From: taitus
Date: Wed, 20 Aug 2025 13:10:28 +0200
Subject: [PATCH 12/15] Reduce duplicated code and simplify code related with
link_to_poll method
---
app/components/polls/poll_component.html.erb | 16 +++--------
app/components/polls/poll_component.rb | 28 +++++++++++++++-----
2 files changed, 26 insertions(+), 18 deletions(-)
diff --git a/app/components/polls/poll_component.html.erb b/app/components/polls/poll_component.html.erb
index 17002ba0c..917f14366 100644
--- a/app/components/polls/poll_component.html.erb
+++ b/app/components/polls/poll_component.html.erb
@@ -8,13 +8,9 @@
- <% if poll.questions.one? %>
-
<%= link_to_poll poll.questions.first.title, poll %>
-
<%= dates %>
- <% else %>
-
<%= link_to_poll poll.name, poll %>
-
<%= dates %>
-
+
<%= link_to header_text, path %>
+
<%= dates %>
+ <% if poll.questions.many? %>
<% poll.questions.sort_for_list.each do |question| %>
<%= question.title %>
@@ -25,9 +21,5 @@
<%= render SDG::TagListComponent.new(poll, limit: 5, linkable: false) %>
- <% if poll.expired? %>
- <%= link_to_poll t("polls.index.participate_button_expired"), poll, class: "button hollow expanded" %>
- <% else %>
- <%= link_to_poll t("polls.index.participate_button"), poll, class: "button hollow expanded" %>
- <% end %>
+ <%= link_to link_text, path, class: "button hollow expanded" %>
diff --git a/app/components/polls/poll_component.rb b/app/components/polls/poll_component.rb
index af70041d7..4e7d465de 100644
--- a/app/components/polls/poll_component.rb
+++ b/app/components/polls/poll_component.rb
@@ -12,13 +12,29 @@ class Polls::PollComponent < ApplicationComponent
t("polls.dates", open_at: l(poll.starts_at.to_date), closed_at: l(poll.ends_at.to_date))
end
- def link_to_poll(text, poll, options = {})
- if can?(:results, poll)
- link_to text, results_poll_path(id: poll.slug || poll.id), options
- elsif can?(:stats, poll)
- link_to text, stats_poll_path(id: poll.slug || poll.id), options
+ def header_text
+ if poll.questions.one?
+ poll.questions.first.title
else
- link_to text, poll_path(id: poll.slug || poll.id), options
+ poll.name
+ end
+ end
+
+ def link_text
+ if poll.expired?
+ t("polls.index.participate_button_expired")
+ else
+ t("polls.index.participate_button")
+ end
+ end
+
+ def path
+ if can?(:results, poll)
+ results_poll_path(id: poll.slug || poll.id)
+ elsif can?(:stats, poll)
+ stats_poll_path(id: poll.slug || poll.id)
+ else
+ poll_path(id: poll.slug || poll.id)
end
end
end
From 2a2edd17d151552596726a729e3e60c5d1a9ff00 Mon Sep 17 00:00:00 2001
From: taitus
Date: Thu, 21 Aug 2025 07:56:11 +0200
Subject: [PATCH 13/15] Move results specs to Polls::ResultsComponent
Running tests at the component level is faster than at the system level,
so we move tests from system/polls/results_spec.rb to the component.
Note that moving these tests removes vote_for_poll_via_web and the visit
to results_poll_path, but both are already covered in other tests. We
also take the opportunity to reuse the method in another test where
it makes sense.
Additionally, the spec title has been reverted from "Results for polls
with questions but without options" to "renders results for polls with
questions but without answers", as it was before commit 8997ed316c7b.
---
.../polls/results_component.html.erb | 2 +-
.../polls/results_component_spec.rb | 49 ++++++++++++++++
spec/system/polls/polls_spec.rb | 8 +--
spec/system/polls/results_spec.rb | 58 -------------------
spec/system/polls/voter_spec.rb | 5 +-
5 files changed, 53 insertions(+), 69 deletions(-)
create mode 100644 spec/components/polls/results_component_spec.rb
delete mode 100644 spec/system/polls/results_spec.rb
diff --git a/app/components/polls/results_component.html.erb b/app/components/polls/results_component.html.erb
index cb43c0596..13fc53a43 100644
--- a/app/components/polls/results_component.html.erb
+++ b/app/components/polls/results_component.html.erb
@@ -3,7 +3,7 @@
<%= render Polls::PollHeaderComponent.new(poll) %>
- <%= render "poll_subnav" %>
+ <%= render "polls/poll_subnav" %>
diff --git a/spec/components/polls/results_component_spec.rb b/spec/components/polls/results_component_spec.rb
new file mode 100644
index 000000000..26c086215
--- /dev/null
+++ b/spec/components/polls/results_component_spec.rb
@@ -0,0 +1,49 @@
+require "rails_helper"
+
+describe Polls::ResultsComponent do
+ let(:poll) { create(:poll) }
+
+ let(:question_1) { create(:poll_question, poll: poll, title: "Do you like Consul Democracy?") }
+ let(:option_yes) { create(:poll_question_option, question: question_1, title: "Yes") }
+ let(:option_no) { create(:poll_question_option, question: question_1, title: "No") }
+
+ let(:question_2) { create(:poll_question, poll: poll, title: "What's your favorite color?") }
+ let(:option_blue) { create(:poll_question_option, question: question_2, title: "Blue") }
+ let(:option_green) { create(:poll_question_option, question: question_2, title: "Green") }
+ let(:option_yellow) { create(:poll_question_option, question: question_2, title: "Yellow") }
+
+ it "renders results content" do
+ create_list(:poll_answer, 2, question: question_1, option: option_yes)
+ create(:poll_answer, question: question_1, option: option_no)
+
+ create(:poll_answer, question: question_2, option: option_blue)
+ create(:poll_answer, question: question_2, option: option_green)
+ create(:poll_answer, question: question_2, option: option_yellow)
+
+ render_inline Polls::ResultsComponent.new(poll)
+
+ expect(page).to have_content "Do you like Consul Democracy?"
+
+ page.find("#question_#{question_1.id}_results_table") do |table|
+ expect(table).to have_css "#option_#{option_yes.id}_result", text: "2 (66.67%)", normalize_ws: true
+ expect(table).to have_css "#option_#{option_no.id}_result", text: "1 (33.33%)", normalize_ws: true
+ end
+
+ expect(page).to have_content "What's your favorite color?"
+
+ page.find("#question_#{question_2.id}_results_table") do |table|
+ expect(table).to have_css "#option_#{option_blue.id}_result", text: "1 (33.33%)", normalize_ws: true
+ expect(table).to have_css "#option_#{option_green.id}_result", text: "1 (33.33%)", normalize_ws: true
+ expect(table).to have_css "#option_#{option_yellow.id}_result", text: "1 (33.33%)", normalize_ws: true
+ end
+ end
+
+ it "renders results for polls with questions but without answers" do
+ poll = create(:poll, :expired, results_enabled: true)
+ question = create(:poll_question, poll: poll)
+
+ render_inline Polls::ResultsComponent.new(poll)
+
+ expect(page).to have_content question.title
+ end
+end
diff --git a/spec/system/polls/polls_spec.rb b/spec/system/polls/polls_spec.rb
index c18cbbdd2..d9d3fdd42 100644
--- a/spec/system/polls/polls_spec.rb
+++ b/spec/system/polls/polls_spec.rb
@@ -180,15 +180,11 @@ describe "Polls" do
scenario "Level 2 users answering" do
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, title: "Do you agree?")
login_as(create(:user, :level_two, geozone: geozone))
- visit poll_path(poll)
+ vote_for_poll_via_web(poll, question => "Yes")
- within_fieldset("Do you agree?") { choose "Yes" }
- click_button "Vote"
-
- expect(page).to have_content "Thank you for voting!"
expect(page).to have_content "You have already participated in this poll. " \
"If you vote again it will be overwritten."
diff --git a/spec/system/polls/results_spec.rb b/spec/system/polls/results_spec.rb
deleted file mode 100644
index 9e269d3ff..000000000
--- a/spec/system/polls/results_spec.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-require "rails_helper"
-
-describe "Poll Results" do
- scenario "List each Poll question" do
- user1 = create(:user, :level_two)
- user2 = create(:user, :level_two)
- user3 = create(:user, :level_two)
-
- poll = create(:poll, results_enabled: true)
- question1 = create(:poll_question, poll: poll)
- option1 = create(:poll_question_option, question: question1, title: "Yes")
- option2 = create(:poll_question_option, question: question1, title: "No")
-
- question2 = create(:poll_question, poll: poll)
- option3 = create(:poll_question_option, question: question2, title: "Blue")
- option4 = create(:poll_question_option, question: question2, title: "Green")
- option5 = create(:poll_question_option, question: question2, title: "Yellow")
-
- login_as user1
- vote_for_poll_via_web(poll, question1 => "Yes", question2 => "Blue")
- logout
-
- login_as user2
- vote_for_poll_via_web(poll, question1 => "Yes", question2 => "Green")
- logout
-
- login_as user3
- vote_for_poll_via_web(poll, question1 => "No", question2 => "Yellow")
- logout
-
- travel_to(poll.ends_at + 1.day)
-
- visit results_poll_path(poll)
-
- expect(page).to have_content(question1.title)
- expect(page).to have_content(question2.title)
-
- within("#question_#{question1.id}_results_table") do
- expect(find("#option_#{option1.id}_result")).to have_content("2 (66.67%)")
- expect(find("#option_#{option2.id}_result")).to have_content("1 (33.33%)")
- end
-
- within("#question_#{question2.id}_results_table") do
- expect(find("#option_#{option3.id}_result")).to have_content("1 (33.33%)")
- expect(find("#option_#{option4.id}_result")).to have_content("1 (33.33%)")
- expect(find("#option_#{option5.id}_result")).to have_content("1 (33.33%)")
- end
- end
-
- scenario "Results for polls with questions but without options" do
- poll = create(:poll, :expired, results_enabled: true)
- question = create(:poll_question, poll: poll)
-
- visit results_poll_path(poll)
-
- expect(page).to have_content question.title
- end
-end
diff --git a/spec/system/polls/voter_spec.rb b/spec/system/polls/voter_spec.rb
index 0a4f2947f..291fec82c 100644
--- a/spec/system/polls/voter_spec.rb
+++ b/spec/system/polls/voter_spec.rb
@@ -18,12 +18,9 @@ describe "Voter" do
user = create(:user, :level_two)
login_as user
- visit poll_path(poll)
- within_fieldset("Is this question stupid?") { choose "Yes" }
- click_button "Vote"
+ vote_for_poll_via_web(poll, question => "Yes")
- expect(page).to have_content "Thank you for voting!"
expect(page).to have_content "You have already participated in this poll. " \
"If you vote again it will be overwritten."
end
From f3050a1aa5e59018fbd0b2ab7f36a4f67451805e Mon Sep 17 00:00:00 2001
From: taitus
Date: Fri, 22 Aug 2025 07:44:39 +0200
Subject: [PATCH 14/15] Manage correctly results and stats for open-ended
questions
Note that we are not including Poll::PartialResults for open-ended
questions resutls. The reason is that we do not contemplate the
possibility of there being open questions in booths. Manually
counting and introducing the votes in the system is not feasible.
---
.../polls/results/question_component.html.erb | 63 +++++++++-----
app/models/poll/question.rb | 24 ++++++
config/locales/en/general.yml | 3 +
config/locales/es/general.yml | 3 +
.../polls/results/question_component_spec.rb | 47 +++++++++++
.../polls/results_component_spec.rb | 49 ++++-------
spec/models/poll/question_spec.rb | 83 +++++++++++++++++++
7 files changed, 220 insertions(+), 52 deletions(-)
create mode 100644 spec/components/polls/results/question_component_spec.rb
diff --git a/app/components/polls/results/question_component.html.erb b/app/components/polls/results/question_component.html.erb
index 512d012f8..422ec44bb 100644
--- a/app/components/polls/results/question_component.html.erb
+++ b/app/components/polls/results/question_component.html.erb
@@ -1,25 +1,46 @@
<%= question.title %>
-
-
- <%- question.question_options.each do |option| %>
-
- <% if most_voted_option?(option) %>
- <%= t("polls.show.results.most_voted_answer") %>
- <% end %>
- <%= option.title %>
-
- <% end %>
-
-
-
-
- <%- question.question_options.each do |option| %>
-
- <%= option.total_votes %>
- (<%= option.total_votes_percentage.round(2) %>%)
+ <% if question.accepts_options? %>
+
+
+ <%- question.question_options.each do |option| %>
+
+ <% if most_voted_option?(option) %>
+ <%= t("polls.show.results.most_voted_answer") %>
+ <% end %>
+ <%= option.title %>
+
+ <% end %>
+
+
+
+
+ <%- question.question_options.each do |option| %>
+
+ <%= option.total_votes %>
+ (<%= option.total_votes_percentage.round(2) %>%)
+
+ <% end %>
+
+
+ <% else %>
+
+
+ <%= t("polls.show.results.open_ended.valid") %>
+ <%= t("polls.show.results.open_ended.blank") %>
+
+
+
+
+
+ <%= question.open_ended_valid_answers_count %>
+ (<%= question.open_ended_valid_percentage.round(2) %>%)
- <% end %>
-
-
+
+ <%= question.open_ended_blank_answers_count %>
+ (<%= question.open_ended_blank_percentage.round(2) %>%)
+
+
+
+ <% end %>
diff --git a/app/models/poll/question.rb b/app/models/poll/question.rb
index f40d47d33..d6c9fc763 100644
--- a/app/models/poll/question.rb
+++ b/app/models/poll/question.rb
@@ -87,6 +87,26 @@ class Poll::Question < ApplicationRecord
answer
end
+ def open_ended_valid_answers_count
+ answers.count
+ end
+
+ def open_ended_blank_answers_count
+ poll.voters.count - open_ended_valid_answers_count
+ end
+
+ def open_ended_valid_percentage
+ return 0.0 if open_ended_total_answers.zero?
+
+ (open_ended_valid_answers_count * 100.0) / open_ended_total_answers
+ end
+
+ def open_ended_blank_percentage
+ return 0.0 if open_ended_total_answers.zero?
+
+ (open_ended_blank_answers_count * 100.0) / open_ended_total_answers
+ end
+
private
def find_by_attributes(user, option_id)
@@ -96,4 +116,8 @@ class Poll::Question < ApplicationRecord
{ author: user }
end
end
+
+ def open_ended_total_answers
+ open_ended_valid_answers_count + open_ended_blank_answers_count
+ end
end
diff --git a/config/locales/en/general.yml b/config/locales/en/general.yml
index f4f37c0a9..ae79e1ba7 100644
--- a/config/locales/en/general.yml
+++ b/config/locales/en/general.yml
@@ -639,6 +639,9 @@ en:
results:
title: "Questions"
most_voted_answer: "Most voted answer: "
+ open_ended:
+ valid: Valid
+ blank: Blank
poll_header:
back_to_proposal: Back to proposal
poll_questions:
diff --git a/config/locales/es/general.yml b/config/locales/es/general.yml
index ca07be430..dd7e63c3e 100644
--- a/config/locales/es/general.yml
+++ b/config/locales/es/general.yml
@@ -639,6 +639,9 @@ es:
results:
title: "Preguntas"
most_voted_answer: "Respuesta más votada: "
+ open_ended:
+ valid: Válidos
+ blank: En blanco
poll_header:
back_to_proposal: Volver a la propuesta
poll_questions:
diff --git a/spec/components/polls/results/question_component_spec.rb b/spec/components/polls/results/question_component_spec.rb
new file mode 100644
index 000000000..562bb0d28
--- /dev/null
+++ b/spec/components/polls/results/question_component_spec.rb
@@ -0,0 +1,47 @@
+require "rails_helper"
+
+describe Polls::Results::QuestionComponent do
+ context "question that accepts options" do
+ let(:question) { create(:poll_question, :yes_no) }
+ let(:option_yes) { question.question_options.find_by(title: "Yes") }
+ let(:option_no) { question.question_options.find_by(title: "No") }
+
+ it "renders results table content" do
+ create(:poll_answer, question: question, option: option_yes)
+ create(:poll_answer, question: question, option: option_no)
+
+ render_inline Polls::Results::QuestionComponent.new(question)
+
+ expect(page).to have_table with_rows: [{ "Most voted answer: Yes" => "1 (50.0%)",
+ "No" => "1 (50.0%)" }]
+
+ page.find("table") do |table|
+ expect(table).to have_css "th.win", count: 1
+ expect(table).to have_css "td.win", count: 1
+ end
+ end
+ end
+
+ context "question that does not accept options" do
+ let(:open_ended_question) { create(:poll_question_open) }
+
+ it "renders open_ended headers and empty counts when there are no participants" do
+ render_inline Polls::Results::QuestionComponent.new(open_ended_question)
+
+ expect(page).to have_table with_rows: [{ "Valid" => "0 (0.0%)",
+ "Blank" => "0 (0.0%)" }]
+ end
+
+ it "renders counts and percentages provided by the model metrics" do
+ allow(open_ended_question).to receive_messages(
+ open_ended_valid_answers_count: 3,
+ open_ended_blank_answers_count: 1
+ )
+
+ render_inline Polls::Results::QuestionComponent.new(open_ended_question)
+
+ expect(page).to have_table with_rows: [{ "Valid" => "3 (75.0%)",
+ "Blank" => "1 (25.0%)" }]
+ end
+ end
+end
diff --git a/spec/components/polls/results_component_spec.rb b/spec/components/polls/results_component_spec.rb
index 26c086215..d3a7257e3 100644
--- a/spec/components/polls/results_component_spec.rb
+++ b/spec/components/polls/results_component_spec.rb
@@ -3,47 +3,34 @@ require "rails_helper"
describe Polls::ResultsComponent do
let(:poll) { create(:poll) }
- let(:question_1) { create(:poll_question, poll: poll, title: "Do you like Consul Democracy?") }
- let(:option_yes) { create(:poll_question_option, question: question_1, title: "Yes") }
- let(:option_no) { create(:poll_question_option, question: question_1, title: "No") }
+ let(:question_1) { create(:poll_question, :yes_no, poll: poll, title: "Do you like Consul Democracy?") }
+ let(:option_yes) { question_1.question_options.find_by(title: "Yes") }
+ let(:option_no) { question_1.question_options.find_by(title: "No") }
- let(:question_2) { create(:poll_question, poll: poll, title: "What's your favorite color?") }
- let(:option_blue) { create(:poll_question_option, question: question_2, title: "Blue") }
- let(:option_green) { create(:poll_question_option, question: question_2, title: "Green") }
- let(:option_yellow) { create(:poll_question_option, question: question_2, title: "Yellow") }
+ let(:question_2) { create(:poll_question, :abc, poll: poll, title: "Which option do you prefer?") }
+ let(:option_a) { question_2.question_options.find_by(title: "Answer A") }
+ let(:option_b) { question_2.question_options.find_by(title: "Answer B") }
+ let(:option_c) { question_2.question_options.find_by(title: "Answer C") }
it "renders results content" do
create_list(:poll_answer, 2, question: question_1, option: option_yes)
create(:poll_answer, question: question_1, option: option_no)
- create(:poll_answer, question: question_2, option: option_blue)
- create(:poll_answer, question: question_2, option: option_green)
- create(:poll_answer, question: question_2, option: option_yellow)
+ create(:poll_answer, question: question_2, option: option_a)
+ create(:poll_answer, question: question_2, option: option_b)
+ create(:poll_answer, question: question_2, option: option_c)
render_inline Polls::ResultsComponent.new(poll)
expect(page).to have_content "Do you like Consul Democracy?"
+ expect(page).to have_table "question_#{question_1.id}_results_table",
+ with_rows: [{ "Most voted answer: Yes" => "2 (66.67%)",
+ "No" => "1 (33.33%)" }]
- page.find("#question_#{question_1.id}_results_table") do |table|
- expect(table).to have_css "#option_#{option_yes.id}_result", text: "2 (66.67%)", normalize_ws: true
- expect(table).to have_css "#option_#{option_no.id}_result", text: "1 (33.33%)", normalize_ws: true
- end
-
- expect(page).to have_content "What's your favorite color?"
-
- page.find("#question_#{question_2.id}_results_table") do |table|
- expect(table).to have_css "#option_#{option_blue.id}_result", text: "1 (33.33%)", normalize_ws: true
- expect(table).to have_css "#option_#{option_green.id}_result", text: "1 (33.33%)", normalize_ws: true
- expect(table).to have_css "#option_#{option_yellow.id}_result", text: "1 (33.33%)", normalize_ws: true
- end
- end
-
- it "renders results for polls with questions but without answers" do
- poll = create(:poll, :expired, results_enabled: true)
- question = create(:poll_question, poll: poll)
-
- render_inline Polls::ResultsComponent.new(poll)
-
- expect(page).to have_content question.title
+ expect(page).to have_content "Which option do you prefer?"
+ expect(page).to have_table "question_#{question_2.id}_results_table",
+ with_rows: [{ "Most voted answer: Answer A" => "1 (33.33%)",
+ "Answer B" => "1 (33.33%)",
+ "Answer C" => "1 (33.33%)" }]
end
end
diff --git a/spec/models/poll/question_spec.rb b/spec/models/poll/question_spec.rb
index f0f5e0882..f882b0503 100644
--- a/spec/models/poll/question_spec.rb
+++ b/spec/models/poll/question_spec.rb
@@ -188,4 +188,87 @@ RSpec.describe Poll::Question do
end
end
end
+
+ context "open-ended results" do
+ let(:poll) { create(:poll) }
+ let!(:question_open) { create(:poll_question_open, poll: poll) }
+
+ it "includes voters who didn't answer any questions in blank answers count" do
+ create(:poll_voter, poll: poll)
+
+ expect(question_open.open_ended_blank_answers_count).to eq 1
+ expect(question_open.open_ended_valid_answers_count).to eq 0
+ end
+
+ describe "#open_ended_valid_answers_count" do
+ it "returns 0 when there are no answers" do
+ expect(question_open.open_ended_valid_answers_count).to eq 0
+ end
+
+ it "counts answers" do
+ create(:poll_answer, question: question_open, answer: "Hello")
+ create(:poll_answer, question: question_open, answer: "Bye")
+
+ expect(question_open.open_ended_valid_answers_count).to eq 2
+ end
+ end
+
+ describe "#open_ended_blank_answers_count" do
+ let(:another_question) { create(:poll_question, :yes_no, poll: poll) }
+ let(:option_yes) { another_question.question_options.find_by(title: "Yes") }
+ let(:option_no) { another_question.question_options.find_by(title: "No") }
+
+ it "counts valid participants of the poll who did not answer the open-ended question" do
+ voters = create_list(:poll_voter, 3, poll: poll)
+ voters.each do |voter|
+ create(:poll_answer, question: another_question, author: voter.user, option: option_yes)
+ end
+ create(:poll_answer, question: question_open, author: voters.sample.user, answer: "Free text")
+
+ expect(question_open.open_ended_valid_answers_count).to eq 1
+ expect(question_open.open_ended_blank_answers_count).to eq 2
+ end
+
+ it "returns 0 when there are no valid participants in the poll" do
+ expect(question_open.open_ended_blank_answers_count).to eq 0
+ end
+
+ it "counts every user one time even if they answered many questions" do
+ multiple_question = create(:poll_question_multiple, :abc, poll: poll)
+ option_a = multiple_question.question_options.find_by(title: "Answer A")
+ option_b = multiple_question.question_options.find_by(title: "Answer B")
+ another_question_open = create(:poll_question_open, poll: poll)
+
+ voter = create(:poll_voter, poll: poll)
+
+ create(:poll_answer, question: multiple_question, author: voter.user, option: option_a)
+ create(:poll_answer, question: multiple_question, author: voter.user, option: option_b)
+ create(:poll_answer, question: another_question, author: voter.user, option: option_yes)
+ create(:poll_answer, question: another_question_open, author: voter.user, answer: "Free text")
+
+ expect(question_open.open_ended_blank_answers_count).to eq 1
+ end
+ end
+
+ describe "percentages" do
+ it "returns 0.0 when there aren't any answers" do
+ expect(question_open.open_ended_valid_percentage).to eq 0.0
+ expect(question_open.open_ended_blank_percentage).to eq 0.0
+ end
+
+ it "calculates valid and blank percentages based on counts" do
+ another_question = create(:poll_question, :yes_no, poll: poll)
+ option_yes = another_question.question_options.find_by(title: "Yes")
+
+ voters = create_list(:poll_voter, 4, poll: poll)
+ voters.each do |voter|
+ create(:poll_answer, question: another_question, author: voter.user, option: option_yes)
+ end
+ create(:poll_answer, question: question_open, author: voters.sample.user, answer: "A")
+
+ expect(question_open.open_ended_valid_percentage).to eq 25.0
+ expect(question_open.open_ended_blank_percentage).to eq 75.0
+ end
+ end
+ end
end
From b1cb6f83729a75375190647e522b33e88b435793 Mon Sep 17 00:00:00 2001
From: taitus
Date: Fri, 22 Aug 2025 14:26:25 +0200
Subject: [PATCH 15/15] Exclude open-ended questions from managing physical
votes
Also make the :yes_no factory trait create a votation_type_unique
by default, since yes/no questions should always be unique.
---
.../officing/results/form_component.html.erb | 2 +-
.../officing/results/index_component.html.erb | 2 +-
app/models/poll/question.rb | 1 +
app/models/votation_type.rb | 2 ++
.../officing/results/form_component_spec.rb | 9 +++++++++
spec/factories/polls.rb | 1 +
spec/models/poll/question_spec.rb | 15 +++++++++++++++
spec/models/votation_type_spec.rb | 16 ++++++++++++++++
spec/system/officing/results_spec.rb | 10 ++++++++--
spec/system/polls/votation_types_spec.rb | 2 +-
10 files changed, 55 insertions(+), 5 deletions(-)
diff --git a/app/components/officing/results/form_component.html.erb b/app/components/officing/results/form_component.html.erb
index d1c7870ec..3572ac33b 100644
--- a/app/components/officing/results/form_component.html.erb
+++ b/app/components/officing/results/form_component.html.erb
@@ -8,7 +8,7 @@
- <% poll.questions.each do |question| %>
+ <% poll.questions.for_physical_votes.each do |question| %>
<%= question.title %>
<% question.question_options.each_with_index do |option, i| %>
diff --git a/app/components/officing/results/index_component.html.erb b/app/components/officing/results/index_component.html.erb
index 046f2319d..98f667ce3 100644
--- a/app/components/officing/results/index_component.html.erb
+++ b/app/components/officing/results/index_component.html.erb
@@ -29,7 +29,7 @@
- <% @poll.questions.each do |question| %>
+ <% @poll.questions.for_physical_votes.each do |question| %>
<%= render Admin::Poll::Results::QuestionComponent.new(question, @partial_results) %>
<% end %>
diff --git a/app/models/poll/question.rb b/app/models/poll/question.rb
index d6c9fc763..ce719493a 100644
--- a/app/models/poll/question.rb
+++ b/app/models/poll/question.rb
@@ -31,6 +31,7 @@ class Poll::Question < ApplicationRecord
scope :sort_for_list, -> { order(Arel.sql("poll_questions.proposal_id IS NULL"), :created_at) }
scope :for_render, -> { includes(:author, :proposal) }
+ scope :for_physical_votes, -> { left_joins(:votation_type).merge(VotationType.accepts_options) }
def copy_attributes_from_proposal(proposal)
if proposal.present?
diff --git a/app/models/votation_type.rb b/app/models/votation_type.rb
index af2758527..2302760c4 100644
--- a/app/models/votation_type.rb
+++ b/app/models/votation_type.rb
@@ -11,6 +11,8 @@ class VotationType < ApplicationRecord
validates :questionable_type, inclusion: { in: ->(*) { QUESTIONABLE_TYPES }}
validates :max_votes, presence: true, if: :max_votes_required?
+ scope :accepts_options, -> { where.not(vote_type: "open") }
+
def accepts_options?
!open?
end
diff --git a/spec/components/officing/results/form_component_spec.rb b/spec/components/officing/results/form_component_spec.rb
index 81f19e38e..b0e46dc2b 100644
--- a/spec/components/officing/results/form_component_spec.rb
+++ b/spec/components/officing/results/form_component_spec.rb
@@ -16,4 +16,13 @@ describe Officing::Results::FormComponent do
expect(page).to have_field "Invalid ballots", with: 0, type: :number
expect(page).to have_field "Valid ballots", with: 0, type: :number
end
+
+ it "does not render open-ended questions" do
+ create(:poll_question_open, poll: poll, title: "What do you want?")
+
+ render_inline Officing::Results::FormComponent.new(poll, Poll::OfficerAssignment.none)
+
+ expect(page).not_to have_content "What do you want?"
+ expect(page).to have_css "fieldset", text: "Agreed?"
+ end
end
diff --git a/spec/factories/polls.rb b/spec/factories/polls.rb
index 155066fd6..a97df79d5 100644
--- a/spec/factories/polls.rb
+++ b/spec/factories/polls.rb
@@ -60,6 +60,7 @@ FactoryBot.define do
trait :yes_no do
after(:create) do |question|
+ create(:votation_type_unique, questionable: question)
create(:poll_question_option, question: question, title: "Yes")
create(:poll_question_option, question: question, title: "No")
end
diff --git a/spec/models/poll/question_spec.rb b/spec/models/poll/question_spec.rb
index f882b0503..36945c8c8 100644
--- a/spec/models/poll/question_spec.rb
+++ b/spec/models/poll/question_spec.rb
@@ -189,6 +189,21 @@ RSpec.describe Poll::Question do
end
end
+ describe "scopes" do
+ describe ".for_physical_votes" do
+ it "returns unique and multiple, but not open_ended" do
+ question_unique = create(:poll_question_unique)
+ question_multiple = create(:poll_question_multiple)
+ question_open_ended = create(:poll_question_open)
+
+ result = Poll::Question.for_physical_votes
+
+ expect(result).to match_array [question_unique, question_multiple]
+ expect(result).not_to include question_open_ended
+ end
+ end
+ end
+
context "open-ended results" do
let(:poll) { create(:poll) }
let!(:question_open) { create(:poll_question_open, poll: poll) }
diff --git a/spec/models/votation_type_spec.rb b/spec/models/votation_type_spec.rb
index b31090a6f..3bae901bd 100644
--- a/spec/models/votation_type_spec.rb
+++ b/spec/models/votation_type_spec.rb
@@ -58,4 +58,20 @@ describe VotationType do
"because you've already defined possible valid answers for this question"
end
end
+
+ describe "scopes" do
+ describe ".accepts_options" do
+ it "includes unique and multiple, excludes open_ended" do
+ question_unique = create(:poll_question_unique)
+ question_multiple = create(:poll_question_multiple)
+ question_open_ended = create(:poll_question_open)
+
+ accepts_options = VotationType.accepts_options
+
+ expect(accepts_options).to match_array [question_unique.votation_type,
+ question_multiple.votation_type]
+ expect(accepts_options).not_to include question_open_ended.votation_type
+ end
+ end
+ end
end
diff --git a/spec/system/officing/results_spec.rb b/spec/system/officing/results_spec.rb
index e17da5857..35348b95c 100644
--- a/spec/system/officing/results_spec.rb
+++ b/spec/system/officing/results_spec.rb
@@ -4,8 +4,8 @@ describe "Officing Results", :with_frozen_time do
let(:poll) { create(:poll, ends_at: 1.day.ago) }
let(:booth) { create(:poll_booth, polls: [poll]) }
let(:poll_officer) { create(:poll_officer) }
- let(:question_1) { create(:poll_question, poll: poll) }
- let(:question_2) { create(:poll_question, poll: poll) }
+ let(:question_1) { create(:poll_question_unique, poll: poll) }
+ let(:question_2) { create(:poll_question_unique, poll: poll) }
before do
create(:poll_shift, :recount_scrutiny_task, officer: poll_officer, booth: booth, date: Date.current)
@@ -15,6 +15,8 @@ describe "Officing Results", :with_frozen_time do
create(:poll_question_option, title: "Today", question: question_2, given_order: 1)
create(:poll_question_option, title: "Tomorrow", question: question_2, given_order: 2)
+ create(:poll_question_open, poll: poll, title: "What do you want?")
+
login_as(poll_officer.user)
set_officing_booth(booth)
end
@@ -69,6 +71,8 @@ describe "Officing Results", :with_frozen_time do
fill_in "Tomorrow", with: "444"
end
+ expect(page).not_to have_css "fieldset", text: "What do you want?"
+
fill_in "Totally blank ballots", with: "66"
fill_in "Invalid ballots", with: "77"
fill_in "Valid ballots", with: "88"
@@ -100,6 +104,7 @@ describe "Officing Results", :with_frozen_time do
booth_assignment_id: partial_result.booth_assignment_id)
within("#question_#{question_1.id}_0_result") { expect(page).to have_content("7777") }
+ expect(page).not_to have_content "What do you want?"
visit new_officing_poll_result_path(poll)
@@ -136,5 +141,6 @@ describe "Officing Results", :with_frozen_time do
within("#total_results") { expect(page).to have_content("8") }
within("#question_#{question_1.id}_0_result") { expect(page).to have_content("5555") }
within("#question_#{question_1.id}_1_result") { expect(page).to have_content("200") }
+ expect(page).not_to have_content "What do you want?"
end
end
diff --git a/spec/system/polls/votation_types_spec.rb b/spec/system/polls/votation_types_spec.rb
index 444c785de..9d5de41b5 100644
--- a/spec/system/polls/votation_types_spec.rb
+++ b/spec/system/polls/votation_types_spec.rb
@@ -9,7 +9,7 @@ describe "Poll Votation Type" do
end
scenario "Unique, multiple and open answers" do
- create(:poll_question_unique, :yes_no, poll: poll, title: "Is it that bad?")
+ create(:poll_question, :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?")
create(:poll_question_open, poll: poll, title: "What do you think?")