diff --git a/app/assets/javascripts/admin/votation_types/fields.js b/app/assets/javascripts/admin/votation_types/fields.js new file mode 100644 index 000000000..85649e675 --- /dev/null +++ b/app/assets/javascripts/admin/votation_types/fields.js @@ -0,0 +1,21 @@ +(function() { + "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 { + $(".max-votes").show(); + $(".description-unique").hide(); + $(".description-multiple").show(); + $(".votation-type-max-votes").prop("disabled", false); + } + }, + initialize: function() { + $(".votation-type-vote-type").on("change", App.AdminVotationTypesFields.adjustForm).trigger("change"); + } + }; +}).call(this); diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 52e178b4b..58f344977 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -167,6 +167,7 @@ var initialize_modules = function() { } App.AdminBudgetsWizardCreationStep.initialize(); App.AdminMachineLearningScripts.initialize(); + App.AdminVotationTypesFields.initialize(); App.BudgetEditAssociations.initialize(); App.BudgetHideMoney.initialize(); App.Datepicker.initialize(); diff --git a/app/assets/javascripts/sortable.js b/app/assets/javascripts/sortable.js index 83a3d4721..ed085d5bd 100644 --- a/app/assets/javascripts/sortable.js +++ b/app/assets/javascripts/sortable.js @@ -9,7 +9,7 @@ attribute: "data-answer-id" }); $.ajax({ - url: $(".sortable").data("js-url"), + url: $(this).data("js-url"), data: { ordered_list: new_order }, diff --git a/app/components/admin/poll/questions/form_component.html.erb b/app/components/admin/poll/questions/form_component.html.erb index fc183f186..bd72edd40 100644 --- a/app/components/admin/poll/questions/form_component.html.erb +++ b/app/components/admin/poll/questions/form_component.html.erb @@ -29,6 +29,14 @@ <% end %> +
+
+ <%= f.fields_for :votation_type do |votation_f| %> + <%= render Admin::VotationTypes::FieldsComponent.new(form: votation_f) %> + <% end %> +
+
+
<%= f.submit(class: "button success expanded", value: t("shared.save")) %> diff --git a/app/components/admin/votation_types/fields_component.html.erb b/app/components/admin/votation_types/fields_component.html.erb new file mode 100644 index 000000000..f55d74579 --- /dev/null +++ b/app/components/admin/votation_types/fields_component.html.erb @@ -0,0 +1,20 @@ +
+ <%= form.enum_select :vote_type, {}, class: "votation-type-vote-type" %> +
+ +
+
+ + <%= t("admin.polls.votation_type.unique_description") %> + + +
+
+ +
+
+ <%= form.number_field :max_votes, min: 2, max: 999, class: "votation-type-max-votes" %> +
+
diff --git a/app/components/admin/votation_types/fields_component.rb b/app/components/admin/votation_types/fields_component.rb new file mode 100644 index 000000000..6fe487397 --- /dev/null +++ b/app/components/admin/votation_types/fields_component.rb @@ -0,0 +1,7 @@ +class Admin::VotationTypes::FieldsComponent < ApplicationComponent + attr_reader :form + + def initialize(form:) + @form = form + end +end diff --git a/app/components/polls/questions/answers_component.html.erb b/app/components/polls/questions/answers_component.html.erb index 91be9ab15..c73247ec2 100644 --- a/app/components/polls/questions/answers_component.html.erb +++ b/app/components/polls/questions/answers_component.html.erb @@ -1,16 +1,22 @@
<% if can?(:answer, question) && !question.poll.voted_in_booth?(current_user) %> <% question_answers.each do |question_answer| %> - <% if already_answered?(question_answer) && !voted_before_sign_in? %> - "> + <% if already_answered?(question_answer) %> + <%= button_to question_answer_path(question, user_answer(question_answer)), + method: :delete, + remote: true, + title: t("poll_questions.show.voted", answer: question_answer.title), + class: "button answered", + "aria-pressed": true do %> <%= question_answer.title %> - + <% end %> <% else %> <%= button_to answer_question_path(question, answer: question_answer.title), remote: true, title: t("poll_questions.show.vote_answer", answer: question_answer.title), - class: "button secondary hollow" do %> + class: "button secondary hollow", + "aria-pressed": false, + disabled: disable_answer?(question_answer) do %> <%= question_answer.title %> <% end %> <% end %> diff --git a/app/components/polls/questions/answers_component.rb b/app/components/polls/questions/answers_component.rb index f51ad4e7f..0509d8fa7 100644 --- a/app/components/polls/questions/answers_component.rb +++ b/app/components/polls/questions/answers_component.rb @@ -7,19 +7,21 @@ class Polls::Questions::AnswersComponent < ApplicationComponent end def already_answered?(question_answer) - user_answers.find_by(answer: question_answer.title).present? - end - - def voted_before_sign_in? - user_answers.any? do |vote| - vote.updated_at < current_user.current_sign_in_at - end + user_answer(question_answer).present? end def question_answers question.question_answers end + def user_answer(question_answer) + user_answers.find_by(answer: question_answer.title) + end + + def disable_answer?(question_answer) + question.multiple? && user_answers.count == question.max_votes + end + private def user_answers diff --git a/app/components/polls/questions/question_component.html.erb b/app/components/polls/questions/question_component.html.erb index 5fd7eeb8b..ac675a889 100644 --- a/app/components/polls/questions/question_component.html.erb +++ b/app/components/polls/questions/question_component.html.erb @@ -3,7 +3,20 @@ <%= question.title %> + <% if question.votation_type.present? %> + + <%= t("poll_questions.description.#{question.vote_type}", maximum: question.max_votes) %> + + <% end %> +
<%= render Polls::Questions::AnswersComponent.new(question) %>
+ + <% if question.answers_with_read_more? %> +
+

<%= t("poll_questions.read_more_about") %>

+

<%= answers_read_more_links %>

+
+ <% end %>
diff --git a/app/components/polls/questions/question_component.rb b/app/components/polls/questions/question_component.rb index d1a4edf27..09b8cacd9 100644 --- a/app/components/polls/questions/question_component.rb +++ b/app/components/polls/questions/question_component.rb @@ -4,4 +4,10 @@ class Polls::Questions::QuestionComponent < ApplicationComponent def initialize(question:) @question = question end + + def answers_read_more_links + safe_join(question.answers_with_read_more.map do |answer| + link_to answer.title, "#answer_#{answer.id}" + end, ", ") + end end diff --git a/app/components/polls/questions/read_more_answer_component.html.erb b/app/components/polls/questions/read_more_answer_component.html.erb deleted file mode 100644 index becece5a7..000000000 --- a/app/components/polls/questions/read_more_answer_component.html.erb +++ /dev/null @@ -1,60 +0,0 @@ -
" id="answer_<%= answer.id %>"> -

<%= answer.title %>

- - <% if answer.images.any? %> - <%= render "polls/gallery", answer: answer %> - <% end %> - - <% if answer.description.present? %> -
-
- <%= wysiwyg(answer.description) %> -
-
- - -
-
- <% end %> - - <% if answer.documents.present? %> - - <% end %> - - <% if answer.videos.present? %> - - <% end %> -
diff --git a/app/components/polls/questions/read_more_answer_component.rb b/app/components/polls/questions/read_more_answer_component.rb deleted file mode 100644 index edb34180c..000000000 --- a/app/components/polls/questions/read_more_answer_component.rb +++ /dev/null @@ -1,9 +0,0 @@ -class Polls::Questions::ReadMoreAnswerComponent < ApplicationComponent - with_collection_parameter :answer - attr_reader :answer - delegate :wysiwyg, to: :helpers - - def initialize(answer:) - @answer = answer - end -end diff --git a/app/components/polls/questions/read_more_component.html.erb b/app/components/polls/questions/read_more_component.html.erb new file mode 100644 index 000000000..27f87d7ed --- /dev/null +++ b/app/components/polls/questions/read_more_component.html.erb @@ -0,0 +1,63 @@ +

<%= question.title %>

+<% question.answers_with_read_more.each do |answer| %> +
" id="answer_<%= answer.id %>"> +

<%= answer.title %>

+ +
+ <% if answer.description.present? %> +
+ <%= wysiwyg(answer.description) %> +
+
+ + +
+ <% end %> + + <% if answer.images.any? %> + <%= render "polls/gallery", answer: answer %> + <% end %> + + <% if answer.documents.present? %> + + <% end %> + + <% if answer.videos.present? %> + + <% end %> +
+
+<% end %> diff --git a/app/components/polls/questions/read_more_component.rb b/app/components/polls/questions/read_more_component.rb new file mode 100644 index 000000000..035cc2888 --- /dev/null +++ b/app/components/polls/questions/read_more_component.rb @@ -0,0 +1,13 @@ +class Polls::Questions::ReadMoreComponent < ApplicationComponent + with_collection_parameter :question + attr_reader :question + delegate :wysiwyg, to: :helpers + + def initialize(question:) + @question = question + end + + def render? + question.answers_with_read_more? + end +end diff --git a/app/controllers/admin/poll/questions_controller.rb b/app/controllers/admin/poll/questions_controller.rb index c0c8540f8..3981f652b 100644 --- a/app/controllers/admin/poll/questions_controller.rb +++ b/app/controllers/admin/poll/questions_controller.rb @@ -14,10 +14,10 @@ class Admin::Poll::QuestionsController < Admin::Poll::BaseController end def new - @polls = Poll.all proposal = Proposal.find(params[:proposal_id]) if params[:proposal_id].present? @question.copy_attributes_from_proposal(proposal) @question.poll = @poll + @question.votation_type = VotationType.new authorize! :create, @question end @@ -58,8 +58,7 @@ class Admin::Poll::QuestionsController < Admin::Poll::BaseController end def allowed_params - attributes = [:poll_id, :question, :proposal_id] - + attributes = [:poll_id, :question, :proposal_id, votation_type_attributes: [:vote_type, :max_votes]] [*attributes, translation_params(Poll::Question)] end diff --git a/app/controllers/polls/answers_controller.rb b/app/controllers/polls/answers_controller.rb new file mode 100644 index 000000000..9e4988c05 --- /dev/null +++ b/app/controllers/polls/answers_controller.rb @@ -0,0 +1,19 @@ +class Polls::AnswersController < ApplicationController + load_and_authorize_resource :question, class: "::Poll::Question" + load_and_authorize_resource :answer, class: "::Poll::Answer", + through: :question, + through_association: :answers + + def destroy + @answer.destroy_and_remove_voter_participation + + respond_to do |format| + format.html do + redirect_to request.referer + end + format.js do + render "polls/questions/answers" + end + end + end +end diff --git a/app/controllers/polls/questions_controller.rb b/app/controllers/polls/questions_controller.rb index 3731aa2f9..a21acc9bc 100644 --- a/app/controllers/polls/questions_controller.rb +++ b/app/controllers/polls/questions_controller.rb @@ -5,9 +5,7 @@ class Polls::QuestionsController < ApplicationController has_orders %w[most_voted newest oldest], only: :show def answer - answer = @question.answers.find_or_initialize_by(author: current_user) - - answer.answer = params[:answer] + answer = @question.find_or_initialize_user_answer(current_user, params[:answer]) answer.save_and_record_voter_participation respond_to do |format| @@ -15,7 +13,7 @@ class Polls::QuestionsController < ApplicationController redirect_to request.referer end format.js do - render :answer + render :answers end end end diff --git a/app/controllers/polls_controller.rb b/app/controllers/polls_controller.rb index af2dc563a..ecb8ffadb 100644 --- a/app/controllers/polls_controller.rb +++ b/app/controllers/polls_controller.rb @@ -19,10 +19,7 @@ class PollsController < ApplicationController def show @questions = @poll.questions.for_render.sort_for_list - @poll_questions_answers = Poll::Question::Answer.where(question: @poll.questions) - .with_content.order(:given_order) - @commentable = @poll - @comment_tree = CommentTree.new(@commentable, params[:page], @current_order) + @comment_tree = CommentTree.new(@poll, params[:page], @current_order) end def stats diff --git a/app/models/abilities/common.rb b/app/models/abilities/common.rb index 2da9a057d..6c8d6cdd2 100644 --- a/app/models/abilities/common.rb +++ b/app/models/abilities/common.rb @@ -112,6 +112,9 @@ module Abilities can :answer, Poll::Question do |question| question.answerable_by?(user) end + can :destroy, Poll::Answer do |answer| + answer.author == user && answer.question.answerable_by?(user) + end end can [:create, :show], ProposalNotification, proposal: { author_id: user.id } diff --git a/app/models/concerns/questionable.rb b/app/models/concerns/questionable.rb new file mode 100644 index 000000000..1d0606fc6 --- /dev/null +++ b/app/models/concerns/questionable.rb @@ -0,0 +1,30 @@ +module Questionable + extend ActiveSupport::Concern + + included do + has_one :votation_type, as: :questionable, dependent: :destroy + accepts_nested_attributes_for :votation_type + delegate :max_votes, :multiple?, :vote_type, to: :votation_type, allow_nil: true + end + + def unique? + votation_type.nil? || votation_type.unique? + end + + def find_or_initialize_user_answer(user, title) + answer = answers.find_or_initialize_by(find_by_attributes(user, title)) + answer.answer = title + answer + end + + private + + def find_by_attributes(user, title) + case vote_type + when "unique", nil + { author: user } + when "multiple" + { author: user, answer: title } + end + end +end diff --git a/app/models/poll/answer.rb b/app/models/poll/answer.rb index 685830201..d9d86e9d6 100644 --- a/app/models/poll/answer.rb +++ b/app/models/poll/answer.rb @@ -7,6 +7,7 @@ class Poll::Answer < ApplicationRecord validates :question, presence: true validates :author, presence: true validates :answer, presence: true + validate :max_votes validates :answer, inclusion: { in: ->(a) { a.question.possible_answers }}, unless: ->(a) { a.question.blank? } @@ -21,4 +22,27 @@ class Poll::Answer < ApplicationRecord Poll::Voter.find_or_create_by!(user: author, poll: poll, origin: "web") end end + + def destroy_and_remove_voter_participation + transaction do + destroy! + + if author.poll_answers.where(question_id: poll.question_ids).none? + Poll::Voter.find_by(user: author, poll: poll, origin: "web").destroy! + end + end + end + + private + + def max_votes + return if !question || question&.unique? || persisted? + + author.reload + author.lock! + + 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/app/models/poll/question.rb b/app/models/poll/question.rb index 6f489c8d8..e513d3298 100644 --- a/app/models/poll/question.rb +++ b/app/models/poll/question.rb @@ -1,6 +1,7 @@ class Poll::Question < ApplicationRecord include Measurable include Searchable + include Questionable acts_as_paranoid column: :hidden_at include ActsAsParanoidAliases @@ -73,4 +74,12 @@ class Poll::Question < ApplicationRecord def possible_answers question_answers.joins(:translations).pluck("poll_question_answer_translations.title") end + + def answers_with_read_more? + answers_with_read_more.any? + end + + def answers_with_read_more + question_answers.select(&:with_read_more?) + end end diff --git a/app/models/poll/question/answer.rb b/app/models/poll/question/answer.rb index ea251c180..57aba5609 100644 --- a/app/models/poll/question/answer.rb +++ b/app/models/poll/question/answer.rb @@ -40,4 +40,8 @@ class Poll::Question::Answer < ApplicationRecord def total_votes_percentage question.answers_total_votes.zero? ? 0 : (total_votes * 100.0) / question.answers_total_votes end + + def with_read_more? + description.present? || images.any? || documents.any? || videos.any? + end end diff --git a/app/models/votation_type.rb b/app/models/votation_type.rb new file mode 100644 index 000000000..00fca0ecf --- /dev/null +++ b/app/models/votation_type.rb @@ -0,0 +1,17 @@ +class VotationType < ApplicationRecord + belongs_to :questionable, polymorphic: true + + QUESTIONABLE_TYPES = %w[Poll::Question].freeze + + enum vote_type: %w[unique multiple] + + validates :questionable, presence: true + validates :questionable_type, inclusion: { in: ->(*) { QUESTIONABLE_TYPES }} + validates :max_votes, presence: true, if: :max_votes_required? + + private + + def max_votes_required? + multiple? + end +end diff --git a/app/views/admin/poll/questions/show.html.erb b/app/views/admin/poll/questions/show.html.erb index ec4e9cecc..f6c8ef32b 100644 --- a/app/views/admin/poll/questions/show.html.erb +++ b/app/views/admin/poll/questions/show.html.erb @@ -28,6 +28,21 @@ <%= link_to @question.proposal.title, proposal_path(@question.proposal) %>

<% end %> + + <% if @question.votation_type.present? %> +

+ <%= t("admin.polls.votation_type.title") %> +
+ <%= VotationType.human_attribute_name("vote_type.#{@question.vote_type}") %> +

+ <% if @question.max_votes.present? %> +

+ <%= VotationType.human_attribute_name("max_votes") %> +
+ <%= @question.max_votes %> +

+ <% end %> + <% end %>
diff --git a/app/views/polls/questions/answer.js.erb b/app/views/polls/questions/answers.js.erb similarity index 100% rename from app/views/polls/questions/answer.js.erb rename to app/views/polls/questions/answers.js.erb diff --git a/app/views/polls/show.html.erb b/app/views/polls/show.html.erb index 906595616..22f1fae32 100644 --- a/app/views/polls/show.html.erb +++ b/app/views/polls/show.html.erb @@ -46,7 +46,7 @@
- <%= render Polls::Questions::ReadMoreAnswerComponent.with_collection(@poll_questions_answers) %> + <%= render Polls::Questions::ReadMoreComponent.with_collection(@questions) %>
diff --git a/config/locales/en/activerecord.yml b/config/locales/en/activerecord.yml index c9e4cd7c6..0e9f59e65 100644 --- a/config/locales/en/activerecord.yml +++ b/config/locales/en/activerecord.yml @@ -145,6 +145,9 @@ en: local_census_record: one: Local census record other: Local census records + votation_type: + one: Votation type + other: Votation types attributes: budget: name: "Name" @@ -451,7 +454,7 @@ en: description: Description poll/question/answer/translation: title: Answer - description: Description + description: "Description (optional)" poll/question/answer/video: title: Title url: External video @@ -514,6 +517,12 @@ en: document_number: Document number date_of_birth: Date of birth postal_code: Postal code + votation_type: + max_votes: Maximum number of votes + vote_type: Votation type + votation_type/vote_type: + unique: Unique answer + multiple: Multiple answers errors: models: user: diff --git a/config/locales/en/admin.yml b/config/locales/en/admin.yml index 20635ee3a..b1b68b4b8 100644 --- a/config/locales/en/admin.yml +++ b/config/locales/en/admin.yml @@ -1107,6 +1107,10 @@ en: alert: "This action will remove the poll and all its associated questions." success_notice: "Poll deleted successfully" unable_notice: "You cannot delete a poll that has votes" + votation_type: + 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." questions: index: title: "Questions" diff --git a/config/locales/en/general.yml b/config/locales/en/general.yml index 4a70c1a3e..9d7c80499 100644 --- a/config/locales/en/general.yml +++ b/config/locales/en/general.yml @@ -636,6 +636,10 @@ en: show: vote_answer: "Vote %{answer}" voted: "You have voted %{answer}" + description: + unique: "You can select a maximum of 1 answer." + multiple: "You can select a maximum of %{maximum} answers." + read_more_about: "Read more about:" proposal_notifications: new: title: "Send notification" diff --git a/config/locales/es/activerecord.yml b/config/locales/es/activerecord.yml index b7cf2fc07..58305df15 100644 --- a/config/locales/es/activerecord.yml +++ b/config/locales/es/activerecord.yml @@ -145,6 +145,9 @@ es: local_census_record: one: Registro del censo local other: Registros del censo local + votation_type: + one: Tipo de votación + other: Tipos de votación attributes: budget: name: "Nombre" @@ -451,7 +454,7 @@ es: description: Descripción poll/question/answer/translation: title: Respuesta - description: Descripción + description: "Descripción (opcional)" poll/question/answer/video: title: Título url: Vídeo externo @@ -514,6 +517,12 @@ es: document_number: Número de documento date_of_birth: Fecha de nacimiento postal_code: Código postal + votation_type: + max_votes: Número máximo de votos + vote_type: Tipo de votación + votation_type/vote_type: + unique: Respuesta única + multiple: Respuesta múltiple errors: models: user: diff --git a/config/locales/es/admin.yml b/config/locales/es/admin.yml index d40af634b..08caffd41 100644 --- a/config/locales/es/admin.yml +++ b/config/locales/es/admin.yml @@ -1106,6 +1106,10 @@ es: alert: "Esta acción eliminará la votación y todas sus preguntas asociadas." success_notice: "Votación eliminada correctamente" unable_notice: "No se pueden eliminar votaciones con votos" + votation_type: + 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." questions: index: title: "Preguntas de votaciones" diff --git a/config/locales/es/general.yml b/config/locales/es/general.yml index d731f4bd4..c69b4259e 100644 --- a/config/locales/es/general.yml +++ b/config/locales/es/general.yml @@ -636,6 +636,10 @@ es: show: vote_answer: "Votar %{answer}" voted: "Has votado %{answer}" + description: + unique: "Puedes seleccionar un máximo de 1 respuesta." + multiple: "Puedes seleccionar un máximo de %{maximum} respuestas." + read_more_about: "Leer más:" proposal_notifications: new: title: "Enviar notificación" diff --git a/config/routes/poll.rb b/config/routes/poll.rb index 5b3c03c7a..94cf504b4 100644 --- a/config/routes/poll.rb +++ b/config/routes/poll.rb @@ -6,6 +6,7 @@ resources :polls, only: [:show, :index] do resources :questions, controller: "polls/questions", shallow: true do post :answer, on: :member + resources :answers, controller: "polls/answers", only: :destroy, shallow: false end end diff --git a/db/dev_seeds/polls.rb b/db/dev_seeds/polls.rb index cf9e97fbe..fcdf9f94b 100644 --- a/db/dev_seeds/polls.rb +++ b/db/dev_seeds/polls.rb @@ -55,7 +55,7 @@ end section "Creating Poll Questions & Answers" do Poll.find_each do |poll| - (1..4).to_a.sample.times do + (3..5).to_a.sample.times do question_title = Faker::Lorem.sentence(word_count: 3).truncate(60) + "?" question = Poll::Question.new(author: User.all.sample, title: question_title, @@ -84,6 +84,15 @@ section "Creating Poll Questions & Answers" do 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}", diff --git a/db/migrate/20220323233643_add_votation_types.rb b/db/migrate/20220323233643_add_votation_types.rb new file mode 100644 index 000000000..beca11ef6 --- /dev/null +++ b/db/migrate/20220323233643_add_votation_types.rb @@ -0,0 +1,12 @@ +class AddVotationTypes < ActiveRecord::Migration[5.2] + def change + create_table :votation_types do |t| + t.integer :questionable_id + t.string :questionable_type + t.integer :vote_type + t.integer :max_votes + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index cb93e1c07..20c37835e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1698,6 +1698,15 @@ ActiveRecord::Schema.define(version: 2022_09_15_154808) do t.index ["user_id"], name: "index_visits_on_user_id" end + create_table "votation_types", force: :cascade do |t| + t.integer "questionable_id" + t.string "questionable_type" + t.integer "vote_type" + t.integer "max_votes" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "votes", id: :serial, force: :cascade do |t| t.string "votable_type" t.integer "votable_id" diff --git a/spec/components/polls/questions/answers_component_spec.rb b/spec/components/polls/questions/answers_component_spec.rb index e0a543a16..a5460b83e 100644 --- a/spec/components/polls/questions/answers_component_spec.rb +++ b/spec/components/polls/questions/answers_component_spec.rb @@ -18,31 +18,33 @@ describe Polls::Questions::AnswersComponent do expect(page).to have_button "Yes" expect(page).to have_button "No" + expect(page).to have_css "button[aria-pressed='false']", count: 2 end - it "renders a span instead of a button for existing user answers" do + it "renders button to destroy current user answers" do user = create(:user, :verified) - allow(user).to receive(:current_sign_in_at).and_return(user.created_at) create(:poll_answer, author: user, question: question, answer: "Yes") sign_in(user) render_inline Polls::Questions::AnswersComponent.new(question) - expect(page).to have_selector "span", text: "Yes" - expect(page).not_to have_button "Yes" - expect(page).to have_button "No" + expect(page).to have_button "You have voted Yes" + expect(page).to have_button "Vote No" + expect(page).to have_css "button[aria-pressed='true']", text: "Yes" end - it "hides current answer and shows buttons in successive sessions" do + it "renders disabled buttons when max votes is reached" do user = create(:user, :verified) - create(:poll_answer, author: user, question: question, answer: "Yes") - allow(user).to receive(:current_sign_in_at).and_return(Time.current) + question = create(:poll_question_multiple, :abc, max_votes: 2, author: user) + create(:poll_answer, author: user, question: question, answer: "Answer A") + create(:poll_answer, author: user, question: question, answer: "Answer C") sign_in(user) render_inline Polls::Questions::AnswersComponent.new(question) - expect(page).to have_button "Yes" - expect(page).to have_button "No" + expect(page).to have_button "You have voted Answer A" + expect(page).to have_button "Vote Answer B", disabled: true + expect(page).to have_button "You have voted Answer C" end it "when user is not signed in, renders answers links pointing to user sign in path" do diff --git a/spec/components/polls/questions/question_component_spec.rb b/spec/components/polls/questions/question_component_spec.rb new file mode 100644 index 000000000..4ca884af5 --- /dev/null +++ b/spec/components/polls/questions/question_component_spec.rb @@ -0,0 +1,18 @@ +require "rails_helper" + +describe Polls::Questions::QuestionComponent do + it "renders more information links when any question answer has additional information" do + question = create(:poll_question) + answer_a = create(:poll_question_answer, question: question, title: "Answer A") + answer_b = create(:poll_question_answer, question: question, title: "Answer B") + allow_any_instance_of(Poll::Question::Answer).to receive(:with_read_more?).and_return(true) + + render_inline Polls::Questions::QuestionComponent.new(question: question) + + poll_question = page.find("#poll_question_#{question.id}") + expect(poll_question).to have_content("Read more about") + expect(poll_question).to have_link("Answer A", href: "#answer_#{answer_a.id}") + expect(poll_question).to have_link("Answer B", href: "#answer_#{answer_b.id}") + expect(poll_question).to have_content("Answer A, Answer B") + end +end diff --git a/spec/components/polls/questions/read_more_answer_component_spec.rb b/spec/components/polls/questions/read_more_answer_component_spec.rb deleted file mode 100644 index 4e4902a81..000000000 --- a/spec/components/polls/questions/read_more_answer_component_spec.rb +++ /dev/null @@ -1,32 +0,0 @@ -require "rails_helper" - -describe Polls::Questions::ReadMoreAnswerComponent do - include Rails.application.routes.url_helpers - let(:poll) { create(:poll) } - let(:question) { create(:poll_question, poll: poll) } - let(:answer) { create(:poll_question_answer, question: question) } - - it "renders answers with videos" do - create(:poll_answer_video, answer: answer, title: "Awesome video", url: "youtube.com/watch?v=123") - - render_inline Polls::Questions::ReadMoreAnswerComponent.new(answer: answer) - - expect(page).to have_link("Awesome video", href: "youtube.com/watch?v=123") - end - - it "renders answers with images" do - create(:image, imageable: answer, title: "The yes movement") - - render_inline Polls::Questions::ReadMoreAnswerComponent.new(answer: answer) - - expect(page).to have_css "img[alt='The yes movement']" - end - - it "renders answers with documents" do - create(:document, documentable: answer, title: "The yes movement") - - render_inline Polls::Questions::ReadMoreAnswerComponent.new(answer: answer) - - expect(page).to have_link("The yes movement") - end -end diff --git a/spec/components/polls/questions/read_more_component_spec.rb b/spec/components/polls/questions/read_more_component_spec.rb new file mode 100644 index 000000000..053bc5097 --- /dev/null +++ b/spec/components/polls/questions/read_more_component_spec.rb @@ -0,0 +1,57 @@ +require "rails_helper" + +describe Polls::Questions::ReadMoreComponent do + include Rails.application.routes.url_helpers + let(:poll) { create(:poll) } + let(:question) { create(:poll_question, poll: poll, title: "Question title?") } + let(:answer) { create(:poll_question_answer, question: question) } + + it "renders question title" do + create(:poll_question_answer, question: question, description: "Question answer description") + + render_inline Polls::Questions::ReadMoreComponent.new(question: question) + + expect(page).to have_content "Question title?" + end + + it "renders answers in the given order" do + create(:poll_question_answer, title: "Answer A", question: question, given_order: 2) + create(:poll_question_answer, title: "Answer B", question: question, given_order: 1) + + render_inline Polls::Questions::ReadMoreComponent.new(question: question) + + expect("Answer B").to appear_before("Answer A") + end + + it "does not render when answers does not have more information" do + answer.update!(description: nil) + + render_inline Polls::Questions::ReadMoreComponent.new(question: question) + + expect(page).not_to be_rendered + end + + it "renders answers with videos" do + create(:poll_answer_video, answer: answer, title: "Awesome video", url: "youtube.com/watch?v=123") + + render_inline Polls::Questions::ReadMoreComponent.new(question: question) + + expect(page).to have_link("Awesome video", href: "youtube.com/watch?v=123") + end + + it "renders answers with images" do + create(:image, imageable: answer, title: "The yes movement") + + render_inline Polls::Questions::ReadMoreComponent.new(question: question) + + expect(page).to have_css "img[alt='The yes movement']" + end + + it "renders answers with documents" do + create(:document, documentable: answer, title: "The yes movement") + + render_inline Polls::Questions::ReadMoreComponent.new(question: question) + + expect(page).to have_link("The yes movement") + end +end diff --git a/spec/factories/polls.rb b/spec/factories/polls.rb index 212e7998b..ed493c21c 100644 --- a/spec/factories/polls.rb +++ b/spec/factories/polls.rb @@ -58,6 +58,28 @@ FactoryBot.define do create(:poll_question_answer, question: question, title: "No") end end + + trait :abc do + after(:create) do |question, evaluator| + %w[A B C].each do |letter| + create(:poll_question_answer, question: question, title: "Answer #{letter}") + end + end + end + + factory :poll_question_unique do + after(:create) do |question| + create(:votation_type_unique, questionable: question) + end + end + + factory :poll_question_multiple do + transient { max_votes { 3 } } + + after(:create) do |question, evaluator| + create(:votation_type_multiple, questionable: question, max_votes: evaluator.max_votes) + end + end end factory :poll_question_answer, class: "Poll::Question::Answer" do diff --git a/spec/factories/votation_type.rb b/spec/factories/votation_type.rb new file mode 100644 index 000000000..dfee71b8d --- /dev/null +++ b/spec/factories/votation_type.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + factory :votation_type do + factory :votation_type_unique do + vote_type { "unique" } + end + + factory :votation_type_multiple do + vote_type { "multiple" } + max_votes { 3 } + end + + association :questionable, factory: :poll_question + end +end diff --git a/spec/models/abilities/common_spec.rb b/spec/models/abilities/common_spec.rb index 42194209a..81eb67e38 100644 --- a/spec/models/abilities/common_spec.rb +++ b/spec/models/abilities/common_spec.rb @@ -232,6 +232,18 @@ describe Abilities::Common do it { should_not be_able_to(:answer, expired_poll_question_from_all_geozones) } it { should_not be_able_to(:answer, expired_poll_question_from_other_geozone) } + context "Poll::Answer" do + let(:own_answer) { create(:poll_answer, author: user) } + let(:other_user_answer) { create(:poll_answer) } + let(:expired_poll) { create(:poll, :expired) } + let(:question) { create(:poll_question, :yes_no, poll: expired_poll) } + let(:expired_poll_answer) { create(:poll_answer, author: user, question: question, answer: "Yes") } + + it { should be_able_to(:destroy, own_answer) } + it { should_not be_able_to(:destroy, other_user_answer) } + it { should_not be_able_to(:destroy, expired_poll_answer) } + end + context "without geozone" do before { user.geozone = nil } diff --git a/spec/models/poll/answer_spec.rb b/spec/models/poll/answer_spec.rb index d420e367e..691a09943 100644 --- a/spec/models/poll/answer_spec.rb +++ b/spec/models/poll/answer_spec.rb @@ -23,6 +23,30 @@ describe Poll::Answer do 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 "validates max votes when creating answers at the same time", :race_condition do + author = create(:user, :level_two) + question = create(:poll_question_multiple, :abc, max_votes: 2) + create(:poll_answer, question: question, answer: "Answer A", author: author) + answer = build(:poll_answer, question: question, answer: "Answer B", author: author) + other_answer = build(:poll_answer, question: question, answer: "Answer C", author: author) + + [answer, other_answer].map do |a| + Thread.new { a.save } + end.each(&:join) + + expect(Poll::Answer.count).to be 2 + end + it "is valid for answers included in the Poll::Question's question_answers list" do question = create(:poll_question) create(:poll_question_answer, title: "One", question: question) @@ -82,4 +106,32 @@ describe Poll::Answer do expect(answer).not_to be_persisted end end + + describe "#destroy_and_remove_voter_participation" do + let(:poll) { create(:poll) } + let(:question) { create(:poll_question, :yes_no, poll: poll) } + + it "destroys voter record and answer when it was the only user's answer" do + answer = build(:poll_answer, question: question) + answer.save_and_record_voter_participation + + expect { answer.destroy_and_remove_voter_participation } + .to change { Poll::Answer.count }.by(-1) + .and change { Poll::Voter.count }.by(-1) + end + + it "destroys the answer but does not destroy the voter record when the user + has answered other poll questions" do + answer = build(:poll_answer, question: question) + answer.save_and_record_voter_participation + other_question = create(:poll_question, :yes_no, poll: poll) + other_answer = build(:poll_answer, question: other_question, author: answer.author) + other_answer.save_and_record_voter_participation + + expect(other_answer).to be_persisted + expect { answer.destroy_and_remove_voter_participation } + .to change { Poll::Answer.count }.by(-1) + .and change { Poll::Voter.count }.by(0) + end + end end diff --git a/spec/models/poll/question/answer_spec.rb b/spec/models/poll/question/answer_spec.rb index 2d5b64482..3ee4ddb1e 100644 --- a/spec/models/poll/question/answer_spec.rb +++ b/spec/models/poll/question/answer_spec.rb @@ -34,4 +34,30 @@ describe Poll::Question::Answer do expect(Poll::Question::Answer.with_content).to be_empty end end + + describe "#with_read_more?" do + it "returns false when the answer does not have description, images, videos nor documents" do + poll_question_answer = build(:poll_question_answer, description: nil) + + expect(poll_question_answer.with_read_more?).to be_falsy + end + + it "returns true when the answer has description, images, videos or documents" do + poll_question_answer = build(:poll_question_answer, description: "Answer description") + + expect(poll_question_answer.with_read_more?).to be_truthy + + poll_question_answer = build(:poll_question_answer, :with_image) + + expect(poll_question_answer.with_read_more?).to be_truthy + + poll_question_answer = build(:poll_question_answer, :with_document) + + expect(poll_question_answer.with_read_more?).to be_truthy + + poll_question_answer = build(:poll_question_answer, :with_video) + + expect(poll_question_answer.with_read_more?).to be_truthy + end + end end diff --git a/spec/models/votation_type_spec.rb b/spec/models/votation_type_spec.rb new file mode 100644 index 000000000..4c947ddd6 --- /dev/null +++ b/spec/models/votation_type_spec.rb @@ -0,0 +1,35 @@ +require "rails_helper" + +describe VotationType do + let(:vote_types) { %i[votation_type_unique votation_type_multiple] } + let(:votation_type) { build(vote_types.sample) } + + it "is valid" do + expect(votation_type).to be_valid + end + + it "is not valid without questionable" do + votation_type.questionable = nil + + expect(votation_type).not_to be_valid + end + + it "is not valid when questionable_type is not allowed" do + votation_type.questionable_type = Poll::Answer + + expect(votation_type).not_to be_valid + expect(votation_type.errors[:questionable_type]).to include "is not included in the list" + end + + it "is not valid when max_votes is undefined for multiple votation_type" do + votation_type.max_votes = nil + votation_type.vote_type = "unique" + + expect(votation_type).to be_valid + + votation_type.vote_type = "multiple" + + expect(votation_type).not_to be_valid + expect(votation_type.errors[:max_votes]).to include "can't be blank" + end +end diff --git a/spec/support/common_actions/polls.rb b/spec/support/common_actions/polls.rb index b571472c4..68cc1c6c0 100644 --- a/spec/support/common_actions/polls.rb +++ b/spec/support/common_actions/polls.rb @@ -5,8 +5,8 @@ module Polls within("#poll_question_#{question.id}_answers") do click_button answer - expect(page).to have_css("span.answered", text: answer) - expect(page).not_to have_button(answer) + expect(page).to have_button("You have voted #{answer}") + expect(page).not_to have_button("Vote #{answer}") end end diff --git a/spec/system/admin/poll/questions_spec.rb b/spec/system/admin/poll/questions_spec.rb index 99c81a34c..d6f559fd7 100644 --- a/spec/system/admin/poll/questions_spec.rb +++ b/spec/system/admin/poll/questions_spec.rb @@ -65,6 +65,34 @@ describe "Admin poll questions", :admin do expect(page).not_to have_link "Create question" end + + describe "With votation type" do + before do + poll = create(:poll, :future) + visit admin_poll_path(poll) + click_link "Create question" + end + + scenario "Unique" do + fill_in "Question", with: "Question with unique answer" + select "Unique answer", from: "Votation type" + + click_button "Save" + + expect(page).to have_content "Question with unique answer" + expect(page).to have_content "Unique answer" + end + + scenario "Multiple" do + fill_in "Question", with: "Question with multiple answers" + select "Multiple answers", from: "Votation type" + fill_in "Maximum number of votes", with: 6 + click_button "Save" + + expect(page).to have_content "Question with multiple answers" + expect(page).to have_content "Multiple answers" + end + end end scenario "Create from proposal" do diff --git a/spec/system/polls/polls_spec.rb b/spec/system/polls/polls_spec.rb index 78cfae471..6d15c2d78 100644 --- a/spec/system/polls/polls_spec.rb +++ b/spec/system/polls/polls_spec.rb @@ -183,18 +183,6 @@ describe "Polls" do expect("Second question").to appear_before("Third question") end - scenario "More info answers appear in the given order" do - question = create(:poll_question, poll: poll) - answer1 = create(:poll_question_answer, title: "First", question: question, given_order: 2) - answer2 = create(:poll_question_answer, title: "Second", question: question, given_order: 1) - - visit poll_path(poll) - - within("div.poll-more-info-answers") do - expect(answer2.title).to appear_before(answer1.title) - end - end - scenario "Buttons to slide through images work back and forth" do question = create(:poll_question, :yes_no, poll: poll) create(:image, imageable: question.question_answers.last, title: "The no movement") @@ -258,10 +246,10 @@ describe "Polls" do visit poll_path(poll) within("#poll_question_#{question.id}_answers") do - click_button "Yes" + click_button "Vote Yes" - expect(page).not_to have_button "Yes" - expect(page).to have_button "No" + expect(page).to have_button "You have voted Yes" + expect(page).to have_button "Vote No" end end @@ -278,51 +266,13 @@ describe "Polls" do within("#poll_question_#{question.id}_answers") do click_button "Yes" - expect(page).not_to have_button "Yes" - expect(page).to have_button "No" + expect(page).to have_button "You have voted Yes" + expect(page).to have_button "Vote No" click_button "No" - expect(page).not_to have_button "No" - expect(page).to have_button "Yes" - end - end - - scenario "Level 2 votes, signs out, signs in, votes again" do - poll.update!(geozone_restricted: true) - poll.geozones << geozone - - question = create(:poll_question, :yes_no, poll: poll) - user = create(:user, :level_two, geozone: geozone) - - login_as user - visit poll_path(poll) - - within("#poll_question_#{question.id}_answers") do - click_button "Yes" - - expect(page).not_to have_button "Yes" - expect(page).to have_button "No" - end - - click_link "Sign out" - login_as user - visit poll_path(poll) - within("#poll_question_#{question.id}_answers") do - click_button "Yes" - - expect(page).not_to have_button "Yes" - expect(page).to have_button "No" - end - - click_link "Sign out" - login_as user - visit poll_path(poll) - within("#poll_question_#{question.id}_answers") do - click_button "No" - - expect(page).not_to have_button "No" - expect(page).to have_button "Yes" + expect(page).to have_button "Vote Yes" + expect(page).to have_button "You have voted No" end end @@ -359,7 +309,7 @@ describe "Polls" do within("#poll_question_#{question.id}_answers") do click_button "Yes" - expect(page).not_to have_button "Yes" + expect(page).to have_button "You have voted Yes" expect(page).to have_button "No" end end diff --git a/spec/system/polls/votation_types_spec.rb b/spec/system/polls/votation_types_spec.rb new file mode 100644 index 000000000..51efb7f2c --- /dev/null +++ b/spec/system/polls/votation_types_spec.rb @@ -0,0 +1,69 @@ +require "rails_helper" + +describe "Poll Votation Type" do + let(:author) { create(:user, :level_two) } + + before do + login_as(author) + end + + scenario "Unique answer" do + question = create(:poll_question_unique, :yes_no) + + visit poll_path(question.poll) + + expect(page).to have_content "You can select a maximum of 1 answer." + expect(page).to have_content(question.title) + expect(page).to have_button("Vote Yes") + expect(page).to have_button("Vote No") + + within "#poll_question_#{question.id}_answers" do + click_button "Yes" + + expect(page).to have_button("You have voted Yes") + expect(page).to have_button("Vote No") + + click_button "No" + + expect(page).to have_button("Vote Yes") + expect(page).to have_button("You have voted No") + end + end + + scenario "Multiple answers" do + question = create(:poll_question_multiple, :abc, max_votes: 2) + visit poll_path(question.poll) + + expect(page).to have_content "You can select a maximum of 2 answers." + expect(page).to have_content(question.title) + expect(page).to have_button("Vote Answer A") + expect(page).to have_button("Vote Answer B") + expect(page).to have_button("Vote Answer C") + + within "#poll_question_#{question.id}_answers" do + click_button "Vote Answer A" + + expect(page).to have_button("You have voted Answer A") + + click_button "Vote Answer C" + + expect(page).to have_button("You have voted Answer C") + expect(page).to have_button("Vote Answer B", disabled: true) + + click_button "You have voted Answer A" + + expect(page).to have_button("Vote Answer A") + expect(page).to have_button("Vote Answer B") + + click_button "You have voted Answer C" + + expect(page).to have_button("Vote Answer C") + + click_button "Vote Answer B" + + expect(page).to have_button("You have voted Answer B") + expect(page).to have_button("Vote Answer A") + expect(page).to have_button("Vote Answer C") + end + end +end diff --git a/spec/system/polls/voter_spec.rb b/spec/system/polls/voter_spec.rb index a629dcdf6..46086b2e5 100644 --- a/spec/system/polls/voter_spec.rb +++ b/spec/system/polls/voter_spec.rb @@ -3,12 +3,10 @@ require "rails_helper" describe "Voter" do context "Origin", :with_frozen_time do let(:poll) { create(:poll) } - let(:question) { create(:poll_question, poll: poll) } + let!(:question) { create(:poll_question, :yes_no, poll: poll) } let(:booth) { create(:poll_booth) } let(:officer) { create(:poll_officer) } let(:admin) { create(:administrator) } - let!(:answer_yes) { create(:poll_question_answer, question: question, title: "Yes") } - let!(:answer_no) { create(:poll_question_answer, question: question, title: "No") } before do create(:geozone, :in_census) @@ -23,12 +21,40 @@ describe "Voter" do visit poll_path(poll) within("#poll_question_#{question.id}_answers") do - click_button answer_yes.title - expect(page).not_to have_button(answer_yes.title) + click_button "Vote Yes" + + expect(page).to have_button("You have voted Yes") + expect(page).not_to have_button("Vote Yes") end - expect(Poll::Voter.count).to eq(1) - expect(Poll::Voter.first.origin).to eq("web") + visit poll_path(poll) + + expect(page).to have_content("You have already participated in this poll.") + expect(page).to have_content("If you vote again it will be overwritten") + end + + scenario "Remove vote via web - Standard" do + user = create(:user, :level_two) + create(:poll_answer, question: question, author: user, answer: "Yes") + create(:poll_voter, poll: poll, user: user) + + login_as user + visit poll_path(poll) + + expect(page).to have_content("You have already participated in this poll.") + expect(page).to have_content("If you vote again it will be overwritten") + + within("#poll_question_#{question.id}_answers") do + click_button "You have voted Yes" + + expect(page).to have_button("Vote Yes") + expect(page).to have_button("Vote No") + end + + visit poll_path(poll) + + expect(page).not_to have_content("You have already participated in this poll.") + expect(page).not_to have_content("If you vote again it will be overwritten") end scenario "Voting via web as unverified user" do @@ -38,8 +64,8 @@ describe "Voter" do visit poll_path(poll) within("#poll_question_#{question.id}_answers") do - expect(page).to have_link(answer_yes.title, href: verification_path) - expect(page).to have_link(answer_no.title, href: verification_path) + expect(page).to have_link("Yes", href: verification_path) + expect(page).to have_link("No", href: verification_path) end expect(page).to have_content("You must verify your account in order to answer") @@ -118,7 +144,7 @@ describe "Voter" do scenario "Trying to vote in web and then in booth" do login_as user - vote_for_poll_via_web(poll, question, answer_yes.title) + vote_for_poll_via_web(poll, question, "Yes") expect(Poll::Voter.count).to eq(1) click_link "Sign out" @@ -145,7 +171,7 @@ describe "Voter" do visit poll_path(poll) within("#poll_question_#{question.id}_answers") do - expect(page).not_to have_button(answer_yes.title) + expect(page).not_to have_button("Yes") end expect(page).to have_content "You have already participated in a physical booth. You can not participate again." expect(Poll::Voter.count).to eq(1) @@ -163,31 +189,6 @@ describe "Voter" do expect(page).to have_content "1" end end - - scenario "Trying to vote in web again" do - login_as user - vote_for_poll_via_web(poll, question, answer_yes.title) - expect(Poll::Voter.count).to eq(1) - - visit poll_path(poll) - - expect(page).to have_content "You have already participated in this poll. If you vote again it will be overwritten." - within("#poll_question_#{question.id}_answers") do - expect(page).not_to have_button(answer_yes.title) - end - - unfreeze_time - - click_link "Sign out" - - login_as user - visit poll_path(poll) - - within("#poll_question_#{question.id}_answers") do - expect(page).to have_button(answer_yes.title) - expect(page).to have_button(answer_no.title) - end - end end scenario "Voting in poll and then verifiying account" do @@ -209,7 +210,7 @@ describe "Voter" do visit poll_path(poll) within("#poll_question_#{question.id}_answers") do - expect(page).not_to have_button(answer_yes.title) + expect(page).not_to have_button("Yes") end expect(page).to have_content "You have already participated in a physical booth. You can not participate again."