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") %> - - + <% 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 %> -
- - - - - - - - - - - - - - - - <% @question.question_options.each do |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") %>
<%= 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) %> -
+ + + + + + + + + + + + + + + + + <% @question.question_options.each do |option| %> + + + + + + + + + <% 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") %>
<%= 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 %> 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? %> - - <% 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? %> + + <% 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| %> - - <% end %> - - - - - <%- question.question_options.each do |option| %> - + + <%- question.question_options.each do |option| %> + + <% end %> + + + + + <%- question.question_options.each do |option| %> + + <% end %> + + + <% else %> + + + + + + + + + - <% end %> - - + + + + <% end %>
- <% if most_voted_option?(option) %> - <%= t("polls.show.results.most_voted_answer") %> - <% end %> - <%= option.title %> -
- <%= option.total_votes %> - (<%= option.total_votes_percentage.round(2) %>%) + <% if question.accepts_options? %> +
+ <% if most_voted_option?(option) %> + <%= t("polls.show.results.most_voted_answer") %> + <% end %> + <%= option.title %> +
+ <%= option.total_votes %> + (<%= option.total_votes_percentage.round(2) %>%) +
<%= 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) %>%)
+ <%= question.open_ended_blank_answers_count %> + (<%= question.open_ended_blank_percentage.round(2) %>%) +
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?")