Merge pull request #6061 from consuldemocracy/poll_text_answers

Add support for essay poll questions
This commit is contained in:
Sebastia
2025-10-16 15:30:22 +02:00
committed by GitHub
43 changed files with 856 additions and 293 deletions

View File

@@ -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() {

View File

@@ -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;
}
}

View File

@@ -4,12 +4,9 @@
<div class="small-12 column">
<div class="callout primary">
<span class="description-unique">
<%= t("admin.polls.votation_type.unique_description") %>
</span>
<span class="description-multiple hidden">
<%= t("admin.polls.votation_type.multiple_description") %>
</span>
<% descriptions.each do |vote_type, text| %>
<%= description_tag(vote_type, text) %>
<% end %>
</div>
</div>

View File

@@ -4,4 +4,18 @@ 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"),
open: t("admin.polls.votation_type.open_description")
}
end
def description_tag(vote_type, text)
tag.span(text, data: { vote_type: vote_type })
end
end

View File

@@ -8,7 +8,7 @@
</div>
</div>
<% poll.questions.each do |question| %>
<% poll.questions.for_physical_votes.each do |question| %>
<fieldset class="row">
<legend class="column"><%= question.title %></legend>
<% question.question_options.each_with_index do |option, i| %>

View File

@@ -29,7 +29,7 @@
</tbody>
</table>
<% @poll.questions.each do |question| %>
<% @poll.questions.for_physical_votes.each do |question| %>
<%= render Admin::Poll::Results::QuestionComponent.new(question, @partial_results) %>
<% end %>
</div>

View File

@@ -8,13 +8,9 @@
</div>
<div class="poll-info">
<% if poll.questions.one? %>
<h4><%= link_to_poll poll.questions.first.title, poll %></h4>
<div class="dates"><%= dates %></div>
<% else %>
<h4><%= link_to_poll poll.name, poll %></h4>
<div class="dates"><%= dates %></div>
<h4><%= link_to header_text, path %></h4>
<div class="dates"><%= dates %></div>
<% if poll.questions.many? %>
<ul class="margin-top">
<% poll.questions.sort_for_list.each do |question| %>
<li><%= question.title %></li>
@@ -25,9 +21,5 @@
<%= render SDG::TagListComponent.new(poll, limit: 5, linkable: false) %>
</div>
<% 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" %>
</div>

View File

@@ -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

View File

@@ -1,23 +1,31 @@
<fieldset <%= fieldset_attributes %>>
<legend><%= question.title %></legend>
<% if multiple_choice? %>
<%= multiple_choice_help_text %>
<% question.question_options.each do |option| %>
<%= multiple_choice_field(option) %>
<% if question.open? %>
<div class="poll-question-open-ended">
<%= 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 %>
</div>
<% else %>
<fieldset <%= fieldset_attributes %>>
<legend><%= question.title %></legend>
<% if question.options_with_read_more? %>
<div class="read-more-links">
<p><%= t("poll_questions.read_more_about") %></p>
<p><%= options_read_more_links %></p>
</div>
<% end %>
<%= form.error_for(:"question_#{question.id}") %>
</fieldset>
<% 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? %>
<div class="read-more-links">
<p><%= t("poll_questions.read_more_about") %></p>
<p><%= options_read_more_links %></p>
</div>
<% end %>
<%= form.error_for(:"question_#{question.id}") %>
</fieldset>
<% end %>

View File

@@ -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

View File

@@ -1,25 +1,46 @@
<h3 id="<%= question.title.parameterize %>"><%= question.title %></h3>
<table id="question_<%= question.id %>_results_table">
<thead>
<tr>
<%- question.question_options.each do |option| %>
<th scope="col" class="<%= option_styles(option) %>">
<% if most_voted_option?(option) %>
<span class="show-for-sr"><%= t("polls.show.results.most_voted_answer") %></span>
<% end %>
<%= option.title %>
</th>
<% end %>
</tr>
</thead>
<tbody>
<tr>
<%- question.question_options.each do |option| %>
<td id="option_<%= option.id %>_result" class="<%= option_styles(option) %>">
<%= option.total_votes %>
(<%= option.total_votes_percentage.round(2) %>%)
<% if question.accepts_options? %>
<thead>
<tr>
<%- question.question_options.each do |option| %>
<th scope="col" class="<%= option_styles(option) %>">
<% if most_voted_option?(option) %>
<span class="show-for-sr"><%= t("polls.show.results.most_voted_answer") %></span>
<% end %>
<%= option.title %>
</th>
<% end %>
</tr>
</thead>
<tbody>
<tr>
<%- question.question_options.each do |option| %>
<td class="<%= option_styles(option) %>">
<%= option.total_votes %>
(<%= option.total_votes_percentage.round(2) %>%)
</td>
<% end %>
</tr>
</tbody>
<% else %>
<thead>
<tr>
<th scope="col"><%= t("polls.show.results.open_ended.valid") %></th>
<th scope="col"><%= t("polls.show.results.open_ended.blank") %></th>
</tr>
</thead>
<tbody>
<tr>
<td>
<%= question.open_ended_valid_answers_count %>
(<%= question.open_ended_valid_percentage.round(2) %>%)
</td>
<% end %>
</tr>
</tbody>
<td>
<%= question.open_ended_blank_answers_count %>
(<%= question.open_ended_blank_percentage.round(2) %>%)
</td>
</tr>
</tbody>
<% end %>
</table>

View File

@@ -1,7 +1,7 @@
class Polls::Results::QuestionComponent < ApplicationComponent
attr_reader :question
def initialize(question:)
def initialize(question)
@question = question
end

View File

@@ -3,7 +3,7 @@
<div class="polls-results">
<%= render Polls::PollHeaderComponent.new(poll) %>
<%= render "poll_subnav" %>
<%= render "polls/poll_subnav" %>
<div class="polls-results-content">
<div>
@@ -16,7 +16,9 @@
</div>
<div>
<%= render Polls::Results::QuestionComponent.with_collection(poll.questions) %>
<% poll.questions.each do |question| %>
<%= render Polls::Results::QuestionComponent.new(question) %>
<% end %>
</div>
</div>
</div>

View File

@@ -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|

View File

@@ -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

View File

@@ -27,10 +27,11 @@ 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) }
scope :for_physical_votes, -> { left_joins(:votation_type).merge(VotationType.accepts_options) }
def copy_attributes_from_proposal(proposal)
if proposal.present?
@@ -61,6 +62,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
@@ -69,23 +74,51 @@ 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
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)
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, answer: option.title }
end
end
def open_ended_total_answers
open_ended_valid_answers_count + open_ended_blank_answers_count
end
end

View File

@@ -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

View File

@@ -1,17 +1,31 @@
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 }
enum :vote_type, { unique: 0, multiple: 1, open: 2 }
validates :questionable, presence: true
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
private
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

View File

@@ -35,7 +35,7 @@
<br>
<%= VotationType.human_attribute_name("vote_type.#{@question.vote_type}") %>
</p>
<% if @question.max_votes.present? %>
<% if @question.multiple? %>
<p>
<strong><%= VotationType.human_attribute_name("max_votes") %></strong>
<br>
@@ -46,57 +46,59 @@
</div>
</div>
<div class="clear">
<% 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 %>
<div class="callout warning">
<strong><%= t("admin.questions.no_edit") %></strong>
</div>
<% end %>
</div>
<table class="margin-top">
<caption><%= t("admin.questions.show.valid_answers") %></caption>
<thead>
<tr>
<th><%= t("admin.questions.show.answers.title") %></th>
<th scope="col" class="medium-7"><%= t("admin.questions.show.answers.description") %></th>
<th scope="col" class="text-center"><%= t("admin.questions.show.answers.images") %></th>
<th scope="col" class="text-center"><%= t("admin.questions.show.answers.documents") %></th>
<th scope="col" class="text-center"><%= t("admin.questions.show.answers.videos") %></th>
<th><%= t("admin.actions.actions") %></th>
</tr>
</thead>
<tbody class="sortable" data-js-url="<%= admin_question_options_order_options_path(@question.id) %>">
<% @question.question_options.each do |option| %>
<tr id="<%= dom_id(option) %>" class="poll_question_option" data-option-id="<%= option.id %>">
<td class="align-top"><%= option.title %></td>
<td class="align-top break"><%= wysiwyg(option.description) %></td>
<td class="align-top text-center">
(<%= option.images.count %>)
<br>
<%= link_to t("admin.questions.show.answers.images_list"),
admin_option_images_path(option) %>
</td>
<td class="align-top text-center">
(<%= option.documents.count rescue 0 %>)
<br>
<%= link_to t("admin.questions.show.answers.documents_list"),
admin_option_documents_path(option) %>
</td>
<td class="align-top text-center">
(<%= option.videos.count %>)
<br>
<%= link_to t("admin.questions.show.answers.video_list"),
admin_option_videos_path(option) %>
</td>
<td>
<%= render Admin::Poll::Questions::Options::TableActionsComponent.new(option) %>
</td>
</tr>
<% if @question.accepts_options? %>
<div class="clear">
<% 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 %>
<div class="callout warning">
<strong><%= t("admin.questions.no_edit") %></strong>
</div>
<% end %>
</tbody>
</table>
</div>
<table class="margin-top">
<caption><%= t("admin.questions.show.valid_answers") %></caption>
<thead>
<tr>
<th><%= t("admin.questions.show.answers.title") %></th>
<th scope="col" class="medium-7"><%= t("admin.questions.show.answers.description") %></th>
<th scope="col" class="text-center"><%= t("admin.questions.show.answers.images") %></th>
<th scope="col" class="text-center"><%= t("admin.questions.show.answers.documents") %></th>
<th scope="col" class="text-center"><%= t("admin.questions.show.answers.videos") %></th>
<th><%= t("admin.actions.actions") %></th>
</tr>
</thead>
<tbody class="sortable" data-js-url="<%= admin_question_options_order_options_path(@question.id) %>">
<% @question.question_options.each do |option| %>
<tr id="<%= dom_id(option) %>" class="poll_question_option" data-option-id="<%= option.id %>">
<td class="align-top"><%= option.title %></td>
<td class="align-top break"><%= wysiwyg(option.description) %></td>
<td class="align-top text-center">
(<%= option.images.count %>)
<br>
<%= link_to t("admin.questions.show.answers.images_list"),
admin_option_images_path(option) %>
</td>
<td class="align-top text-center">
(<%= option.documents.count rescue 0 %>)
<br>
<%= link_to t("admin.questions.show.answers.documents_list"),
admin_option_documents_path(option) %>
</td>
<td class="align-top text-center">
(<%= option.videos.count %>)
<br>
<%= link_to t("admin.questions.show.answers.video_list"),
admin_option_videos_path(option) %>
</td>
<td>
<%= render Admin::Poll::Questions::Options::TableActionsComponent.new(option) %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% end %>