From 4badf860cba4f5decfd9bf520040b2b559e50c22 Mon Sep 17 00:00:00 2001 From: Elie Gaboriau Date: Thu, 19 Jan 2023 21:49:02 +0100 Subject: [PATCH 1/8] linter --- .../decidim/forms/step_navigation/show.erb | 37 + .../decidim/forms/step_navigation_cell.rb | 50 + app/packs/src/decidim/decidim_application.js | 34 + .../forms/questionnaires/show.html.erb | 155 ++ config/initializers/decidim_verifications.rb | 16 +- package-lock.json | 28 +- package.json | 2 + spec/factories.rb | 2 + spec/shared/has_questionnaire.rb | 1427 +++++++++++++++++ spec/system/survey_spec.rb | 111 ++ yarn.lock | 13 +- 11 files changed, 1863 insertions(+), 12 deletions(-) create mode 100644 app/cells/decidim/forms/step_navigation/show.erb create mode 100644 app/cells/decidim/forms/step_navigation_cell.rb create mode 100644 app/views/decidim/forms/questionnaires/show.html.erb create mode 100644 spec/shared/has_questionnaire.rb create mode 100644 spec/system/survey_spec.rb diff --git a/app/cells/decidim/forms/step_navigation/show.erb b/app/cells/decidim/forms/step_navigation/show.erb new file mode 100644 index 0000000000..4859140494 --- /dev/null +++ b/app/cells/decidim/forms/step_navigation/show.erb @@ -0,0 +1,37 @@ +
+ <% if first_step? || errors %> + + <% else %> + <%= link_to( + icon("caret-left", class: "icon--small", role: "img", "aria-hidden": true) + " " + t("decidim.forms.step_navigation.show.back"), + "#", + class: "hollow secondary back_survey", + data: { + toggle: [previous_step_dom_id, current_step_dom_id].join(" ") + } + ) %> + <% end %> + + <% if last_step? %> + <%= form.submit( + t("decidim.forms.step_navigation.show.submit"), + class: "button button--sc submit_survey", + disabled: button_disabled?, + data: { + confirm: t("decidim.forms.step_navigation.show.are_you_sure"), + disable: true + } + ) %> + <% elsif errors %> + + <% else %> + <%= link_to( + t("decidim.forms.step_navigation.show.continue"), + "#", + class: "button button--sc next_survey", + data: { + toggle: [next_step_dom_id, current_step_dom_id].join(" ") + } + ) %> + <% end %> +
diff --git a/app/cells/decidim/forms/step_navigation_cell.rb b/app/cells/decidim/forms/step_navigation_cell.rb new file mode 100644 index 0000000000..8865eba6ce --- /dev/null +++ b/app/cells/decidim/forms/step_navigation_cell.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Decidim + module Forms + # This cell renders the navigation of a questionnaire step. + class StepNavigationCell < Decidim::ViewModel + include Decidim::LayoutHelper + + def current_step_index + model + end + + def first_step? + current_step_index.zero? + end + + def last_step? + current_step_index + 1 == total_steps + end + + def total_steps + options[:total_steps] + end + + def form + options[:form] + end + + def button_disabled? + options[:button_disabled] + end + + def previous_step_dom_id + "step-#{current_step_index - 1}" + end + + def next_step_dom_id + "step-#{current_step_index + 1}" + end + + def current_step_dom_id + "step-#{current_step_index}" + end + + def errors + options[:errors] + end + end + end +end diff --git a/app/packs/src/decidim/decidim_application.js b/app/packs/src/decidim/decidim_application.js index 5d5dcf59f4..63b5b156c8 100644 --- a/app/packs/src/decidim/decidim_application.js +++ b/app/packs/src/decidim/decidim_application.js @@ -3,3 +3,37 @@ // Load images require.context("../../images", true) + +import $ from "jquery" +import "jquery-validation" + +$(() => { + if($(".submit_survey").length) { + $("body").on('DOMNodeInserted', '.confirm-reveal', function () { + $('.button[aria-label="Ok"]').on("mouseup", function () { + $("form.answer-questionnaire").validate({ + ignore: "thrhwrt", + focusInvalid: false, + invalidHandler: function(form, validator) { + + $(".questionnaire-step").each(function () { + console.log($(this).removeClass("hide")) + }); + $(".next_survey").hide(); + $(".back_survey").hide(); + + if (!validator.numberOfInvalids()) + return; + + const y = validator.errorList[0].element.parentElement.getBoundingClientRect().top + window.pageYOffset - 10; + + window.scrollTo({top: y, behavior: 'smooth'}); + } + }); + if ($("form.answer-questionnaire").valid()) { + $(".survey-form").submit(); + } + }); + }); + } +}); diff --git a/app/views/decidim/forms/questionnaires/show.html.erb b/app/views/decidim/forms/questionnaires/show.html.erb new file mode 100644 index 0000000000..282b00c98a --- /dev/null +++ b/app/views/decidim/forms/questionnaires/show.html.erb @@ -0,0 +1,155 @@ +<% add_decidim_meta_tags({ + title: translated_attribute(questionnaire.title), + description: translated_attribute(questionnaire.description), +}) %> + +<% columns = allow_answers? && visitor_can_answer? && @form.responses.map(&:question).any?(&:matrix?) ? 9 : 6 %> + +<%= render partial: "decidim/shared/component_announcement" if current_component.manifest_name == "surveys" %> + +
+

<%= translated_attribute questionnaire.title %>

+
+
+ <%= decidim_sanitize_editor translated_attribute questionnaire.description %> +
+
+
+ +
+
+
+
+ <% unless questionnaire_for.try(:component)&.try(:published?) %> +
+
+

<%= t(".questionnaire_not_published.body") %>

+
+
+ <% end %> + + <% if allow_answers? %> + <% if visitor_can_answer? %> + <% if visitor_already_answered? %> +
+
+

<%= t(".questionnaire_answered.title") %>

+

<%= t(".questionnaire_answered.body") %>

+
+
+ <% else %> +
+ + <% unless current_participatory_space.can_participate?(current_user) %> +
+
+

<%= t(".questionnaire_for_private_users.title") %>

+

<%= t(".questionnaire_for_private_users.body") %>

+
+
+ <% end %> + + <%= decidim_form_for(@form, url: update_url, method: :post, html: { multipart: true, class: "form answer-questionnaire" }, data: { "safe-path" => form_path }) do |form| %> + <%= form_required_explanation %> + <%= invisible_captcha %> + <% answer_idx = 0 %> + <% cleaned_answer_idx = 0 %> + <% @form.responses_by_step.each_with_index do |step_answers, step_index| %> +
" data-toggler=".hide"> + <% if @form.total_steps > 1 %> +

+ <%= t(".current_step", step: step_index + 1) %> + <%= t(".of_total_steps", total_steps: @form.total_steps) %> +

+ <% end %> + + <% step_answers.each do |answer| %> +
+ <% answer.question.display_conditions.each do |display_condition| %> + <%= content_tag :div, nil, class: "display-condition", data: display_condition.to_html_data %> + <% end %> + + <%= fields_for "questionnaire[responses][#{answer_idx}]", answer do |answer_form| %> + <%= render( + "decidim/forms/questionnaires/answer", + answer_form: answer_form, + answer: answer, + answer_idx: answer_idx, + cleaned_answer_idx: cleaned_answer_idx, + disabled: !current_participatory_space.can_participate?(current_user) + ) %> + <% end %> +
+ <% if !(answer.question.separator? || answer.question.title_and_description?) %> + <% cleaned_answer_idx += 1 %> + <% end %> + <% answer_idx += 1 %> + <% end %> + + <% if step_index + 1 == @form.total_steps %> + <% if show_represent_user_group? %> +
+ <%= cell("decidim/represent_user_group", form) %> +
+ <% end %> + + <% if show_public_participation? %> +
+ <%= cell("decidim/public_participation", form) %> +
+ <% end %> + +
+ <%= form.check_box :tos_agreement, label: t(".tos_agreement"), id: "questionnaire_tos_agreement", disabled: !current_participatory_space.can_participate?(current_user) %> +
+ <%= decidim_sanitize_editor translated_attribute questionnaire.tos %> +
+
+ <% end %> + + <%= cell( + "decidim/forms/step_navigation", + step_index, + total_steps: @form.total_steps, + button_disabled: !current_participatory_space.can_participate?(current_user), + form: form, + errors: @form.errors.any? + ) %> +
+ <% end %> + <% end %> +
+ <% end %> + <% else %> +
+

<%= t(".answer_questionnaire.title") %>

+

+ <%= t(".answer_questionnaire.anonymous_user_message", sign_in_link: decidim.new_user_session_path, sign_up_link: decidim.new_user_registration_path).html_safe %> +

+ +
    + <%= cell("decidim/forms/question_readonly", collection: @questionnaire.questions.not_conditioned) %> +
+
+ <% end %> + <% else %> +
+
+

<%= t(".questionnaire_closed.title") %>

+

<%= t(".questionnaire_closed.body") %>

+
+
+ <% end %> +
+
+
+
+ +<%= javascript_pack_tag "decidim_forms" %> diff --git a/config/initializers/decidim_verifications.rb b/config/initializers/decidim_verifications.rb index 73e6b603dc..a04920f8da 100644 --- a/config/initializers/decidim_verifications.rb +++ b/config/initializers/decidim_verifications.rb @@ -19,6 +19,18 @@ # end # end -Decidim::Verifications.register_workflow(:osp_authorization_handler) do |auth| - auth.form = "Decidim::OspAuthorizationHandler" +if Rails.env == "test" + Decidim::Verifications.register_workflow(:dummy_authorization_handler) do |workflow| + workflow.form = "DummyAuthorizationHandler" + workflow.action_authorizer = "DummyAuthorizationHandler::DummyActionAuthorizer" + workflow.expires_in = 1.hour + + workflow.options do |options| + options.attribute :postal_code, type: :string, default: "08001", required: false + end + end +else + Decidim::Verifications.register_workflow(:osp_authorization_handler) do |auth| + auth.form = "Decidim::OspAuthorizationHandler" + end end diff --git a/package-lock.json b/package-lock.json index 3c8f6abb6a..68e6602850 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,9 @@ "highlight.js": "^11.6.0", "inline-attachment": "^2.0.3", "inscrybmde": "^1.11.6", + "jquery": "^3.6.3", "jquery-ui": "^1.13.2", + "jquery-validation": "^1.19.5", "jsrender": "^1.0.11", "leaflet": "^1.3.4", "leaflet.featuregroup.subgroup": "^1.0.2", @@ -7897,9 +7899,9 @@ } }, "node_modules/jquery": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.1.tgz", - "integrity": "sha512-opJeO4nCucVnsjiXOE+/PcCgYw9Gwpvs/a6B1LL/lQhwWwpbVEVYDZ1FokFr8PRc7ghYlrFPuyHuiiDNTQxmcw==" + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.3.tgz", + "integrity": "sha512-bZ5Sy3YzKo9Fyc8wH2iIQK4JImJ6R0GWI9kL1/k7Z91ZBNgkRXE6U0JfHIizZbort8ZunhSI3jw9I6253ahKfg==" }, "node_modules/jquery-serializejson": { "version": "2.9.0", @@ -7919,6 +7921,14 @@ "resolved": "https://registry.npmjs.org/jquery-ui-sortable/-/jquery-ui-sortable-1.0.0.tgz", "integrity": "sha512-7xAUWoEJ/jHoj48ei8CCUtiad2uM3ie3IR2b3KB0Mpmb54IbBxzVb5vtrj0zqtd0GNQDImx+BPZml9QmK2EL3w==" }, + "node_modules/jquery-validation": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/jquery-validation/-/jquery-validation-1.19.5.tgz", + "integrity": "sha512-X2SmnPq1mRiDecVYL8edWx+yTBZDyC8ohWXFhXdtqFHgU9Wd4KHkvcbCoIZ0JaSaumzS8s2gXSkP8F7ivg/8ZQ==", + "peerDependencies": { + "jquery": "^1.7 || ^2.0 || ^3.1" + } + }, "node_modules/jquery.autocomplete": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/jquery.autocomplete/-/jquery.autocomplete-1.2.0.tgz", @@ -20124,9 +20134,9 @@ } }, "jquery": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.1.tgz", - "integrity": "sha512-opJeO4nCucVnsjiXOE+/PcCgYw9Gwpvs/a6B1LL/lQhwWwpbVEVYDZ1FokFr8PRc7ghYlrFPuyHuiiDNTQxmcw==" + "version": "3.6.3", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.3.tgz", + "integrity": "sha512-bZ5Sy3YzKo9Fyc8wH2iIQK4JImJ6R0GWI9kL1/k7Z91ZBNgkRXE6U0JfHIizZbort8ZunhSI3jw9I6253ahKfg==" }, "jquery-serializejson": { "version": "2.9.0", @@ -20146,6 +20156,12 @@ "resolved": "https://registry.npmjs.org/jquery-ui-sortable/-/jquery-ui-sortable-1.0.0.tgz", "integrity": "sha512-7xAUWoEJ/jHoj48ei8CCUtiad2uM3ie3IR2b3KB0Mpmb54IbBxzVb5vtrj0zqtd0GNQDImx+BPZml9QmK2EL3w==" }, + "jquery-validation": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/jquery-validation/-/jquery-validation-1.19.5.tgz", + "integrity": "sha512-X2SmnPq1mRiDecVYL8edWx+yTBZDyC8ohWXFhXdtqFHgU9Wd4KHkvcbCoIZ0JaSaumzS8s2gXSkP8F7ivg/8ZQ==", + "requires": {} + }, "jquery.autocomplete": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/jquery.autocomplete/-/jquery.autocomplete-1.2.0.tgz", diff --git a/package.json b/package.json index fa47d02918..3782e46bd6 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "highlight.js": "^11.6.0", "inline-attachment": "^2.0.3", "inscrybmde": "^1.11.6", + "jquery": "^3.6.3", "jquery-ui": "^1.13.2", + "jquery-validation": "^1.19.5", "jsrender": "^1.0.11", "leaflet": "^1.3.4", "leaflet.featuregroup.subgroup": "^1.0.2", diff --git a/spec/factories.rb b/spec/factories.rb index a1fa36020a..16258e4b03 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -10,3 +10,5 @@ require "decidim/participatory_processes/test/factories" # require "decidim/decidim_awesome/test/factories" require "decidim/verifications/test/factories" +require "decidim/forms/test/factories" +require "decidim/surveys/test/factories" diff --git a/spec/shared/has_questionnaire.rb b/spec/shared/has_questionnaire.rb new file mode 100644 index 0000000000..9f330dee0a --- /dev/null +++ b/spec/shared/has_questionnaire.rb @@ -0,0 +1,1427 @@ +# frozen_string_literal: true + +require "spec_helper" + +shared_examples_for "has questionnaire" do + context "when the user is not logged in" do + it "does not allow answering the questionnaire" do + visit questionnaire_public_path + + expect(page).to have_i18n_content(questionnaire.title, upcase: true) + expect(page).to have_i18n_content(questionnaire.description) + + expect(page).not_to have_css(".form.answer-questionnaire") + + within ".questionnaire-question_readonly" do + expect(page).to have_i18n_content(question.body) + end + + expect(page).to have_content("Sign in with your account or sign up to answer the form.") + end + end + + context "when the user is logged in" do + before do + login_as user, scope: :user + end + + it "allows answering the questionnaire" do + visit questionnaire_public_path + + expect(page).to have_i18n_content(questionnaire.title, upcase: true) + expect(page).to have_i18n_content(questionnaire.description) + + fill_in question.body["en"], with: "My first answer" + + check "questionnaire_tos_agreement" + + accept_confirm do + click_button "Submit" + end + + within ".success.flash" do + expect(page).to have_content("successfully") + end + + visit questionnaire_public_path + + expect(page).to have_content("You have already answered this form.") + expect(page).to have_no_i18n_content(question.body) + end + + context "with multiple steps" do + let!(:separator) { create(:questionnaire_question, questionnaire: questionnaire, position: 1, question_type: :separator) } + let!(:question2) { create(:questionnaire_question, questionnaire: questionnaire, position: 2) } + + before do + visit questionnaire_public_path + end + + it "allows answering the first questionnaire" do + expect(page).to have_content("STEP 1 OF 2") + + within ".answer-questionnaire__submit" do + expect(page).to have_no_content("Back") + end + + answer_first_questionnaire + + expect(page).to have_no_selector(".success.flash") + end + + it "allows revisiting previously-answered questionnaires with my answers" do + answer_first_questionnaire + + click_link "Back" + + expect(page).to have_content("STEP 1 OF 2") + expect(page).to have_field("questionnaire_responses_0", with: "My first answer") + end + + it "finishes the submission when answering the last questionnaire" do + answer_first_questionnaire + + check "questionnaire_tos_agreement" + accept_confirm { click_button "Submit" } + + within ".success.flash" do + expect(page).to have_content("successfully") + end + + visit questionnaire_public_path + + expect(page).to have_content("You have already answered this form.") + end + + def answer_first_questionnaire + expect(page).to have_no_selector("#questionnaire_tos_agreement") + + fill_in question.body["en"], with: "My first answer" + within ".answer-questionnaire__submit" do + click_link "Continue" + end + expect(page).to have_content("STEP 2 OF 2") + end + end + + it "requires confirmation when exiting mid-answering" do + visit questionnaire_public_path + + fill_in question.body["en"], with: "My first answer" + + dismiss_page_unload do + page.find(".logo-wrapper a").click + end + + expect(page).to have_current_path questionnaire_public_path + end + + context "when the questionnaire has already been answered by someone else" do + let!(:question) do + create( + :questionnaire_question, + questionnaire: questionnaire, + question_type: "single_option", + position: 0, + options: [ + { "body" => Decidim::Faker::Localized.sentence }, + { "body" => Decidim::Faker::Localized.sentence } + ] + ) + end + + before do + answer = create(:answer, id: 1, questionnaire: questionnaire, question: question) + + answer.choices.create!( + answer_option: Decidim::Forms::AnswerOption.first, + body: "Lalalilo" + ) + end + + it "does not leak defaults from other answers" do + visit questionnaire_public_path + + expect(page).to have_no_selector("input[type=radio]:checked") + end + end + + shared_examples_for "a correctly ordered questionnaire" do + it "displays the questions ordered by position starting with one" do + form_fields = all(".answer-questionnaire .row") + + expect(form_fields[0]).to have_i18n_content(question.body).and have_content("1. ") + expect(form_fields[1]).to have_i18n_content(other_question.body).and have_content("2. ") + end + end + + context "and submitting a fresh form" do + let!(:other_question) { create(:questionnaire_question, questionnaire: questionnaire, position: 1) } + + before do + visit questionnaire_public_path + end + + it_behaves_like "a correctly ordered questionnaire" + end + + context "and rendering a form after errors" do + let!(:other_question) { create(:questionnaire_question, questionnaire: questionnaire, position: 1) } + + before do + visit questionnaire_public_path + accept_confirm { click_button "Submit" } + end + + it_behaves_like "a correctly ordered questionnaire" + end + + shared_context "when a non multiple choice question is mandatory" do + let!(:question) do + create( + :questionnaire_question, + questionnaire: questionnaire, + question_type: "short_answer", + position: 0, + mandatory: true + ) + end + let!(:separator) { create(:questionnaire_question, questionnaire: questionnaire, position: 1, question_type: :separator) } + let!(:question2) { create(:questionnaire_question, questionnaire: questionnaire, position: 2) } + + before do + visit questionnaire_public_path + end + end + + shared_examples_for "question has a character limit" do + context "when max_characters value is positive" do + let(:max_characters) { 30 } + + it "shows a message indicating number of characters left" do + visit questionnaire_public_path + + expect(page).to have_content("30 characters left") + end + end + + context "when max_characters value is 0" do + let(:max_characters) { 0 } + + it "doesn't show message indicating number of characters left" do + visit questionnaire_public_path + + expect(page).not_to have_content("characters left") + end + end + end + + describe "leaving a blank question (without js)", driver: :rack_test do + include_context "when a non multiple choice question is mandatory" + + before do + click_button "Submit" + end + + it "submits the form and shows errors" do + within ".alert.flash" do + expect(page).to have_content("problem") + end + + expect(page).to have_content("can't be blank") + end + end + + describe "leaving a blank question (with js)" do + include_context "when a non multiple choice question is mandatory" + + before do + click_link "Continue" + click_button "Submit" + page.execute_script('$(".button[aria-label=\'Ok\']").trigger("mouseup")') + click_link "OK" + end + + it "shows errors without submitting the form" do + expect(page).to have_no_selector ".alert.flash" + different_error = I18n.t("decidim.forms.questionnaires.answer.max_choices_alert") + expect(different_error).to eq("There are too many choices selected") + expect(page).not_to have_content(different_error) + + expect(page).to have_content("can't be blank") + end + end + + describe "leaving a blank multiple choice question" do + let!(:question) do + create( + :questionnaire_question, + questionnaire: questionnaire, + question_type: "single_option", + position: 0, + mandatory: true, + options: [ + { "body" => Decidim::Faker::Localized.sentence }, + { "body" => Decidim::Faker::Localized.sentence } + ] + ) + end + + before do + visit questionnaire_public_path + + check "questionnaire_tos_agreement" + + accept_confirm { click_button "Submit" } + end + + it "submits the form and shows errors" do + within ".alert.flash" do + expect(page).to have_content("problem") + end + + expect(page).to have_content("can't be blank") + end + end + + context "when a question has a rich text description" do + let!(:question) { create(:questionnaire_question, questionnaire: questionnaire, position: 0, description: { en: "This question is important" }) } + + it "properly interprets HTML descriptions" do + visit questionnaire_public_path + + expect(page).to have_selector("b", text: "This question is important") + end + end + + describe "free text options" do + let(:answer_option_bodies) { Array.new(3) { Decidim::Faker::Localized.sentence } } + let(:max_characters) { 0 } + let!(:question) do + create( + :questionnaire_question, + questionnaire: questionnaire, + question_type: question_type, + max_characters: max_characters, + position: 1, + options: [ + { "body" => answer_option_bodies[0] }, + { "body" => answer_option_bodies[1] }, + { "body" => answer_option_bodies[2], "free_text" => true } + ] + ) + end + + let!(:other_question) do + create( + :questionnaire_question, + questionnaire: questionnaire, + question_type: "multiple_option", + max_choices: 2, + position: 2, + options: [ + { "body" => Decidim::Faker::Localized.sentence }, + { "body" => Decidim::Faker::Localized.sentence }, + { "body" => Decidim::Faker::Localized.sentence } + ] + ) + end + + before do + visit questionnaire_public_path + end + + context "when question is single_option type" do + let(:question_type) { "single_option" } + + it "renders them as radio buttons with attached text fields disabled by default" do + expect(page).to have_selector(".radio-button-collection input[type=radio]", count: 3) + + expect(page).to have_field("questionnaire_responses_0_choices_2_custom_body", disabled: true, count: 1) + + choose answer_option_bodies[2]["en"] + + expect(page).to have_field("questionnaire_responses_0_choices_2_custom_body", disabled: false, count: 1) + end + + it "saves the free text in a separate field if submission correct" do + choose answer_option_bodies[2]["en"] + fill_in "questionnaire_responses_0_choices_2_custom_body", with: "Cacatua" + + check "questionnaire_tos_agreement" + accept_confirm { click_button "Submit" } + + within ".success.flash" do + expect(page).to have_content("successfully") + end + + expect(Decidim::Forms::Answer.first.choices.first.custom_body).to eq("Cacatua") + end + + it "preserves the previous custom body if submission not correct" do + check other_question.answer_options.first.body["en"] + check other_question.answer_options.second.body["en"] + check other_question.answer_options.third.body["en"] + + choose answer_option_bodies[2]["en"] + fill_in "questionnaire_responses_0_choices_2_custom_body", with: "Cacatua" + + check "questionnaire_tos_agreement" + accept_confirm { click_button "Submit" } + + within ".alert.flash" do + expect(page).to have_content("There was a problem answering") + end + + expect(page).to have_field("questionnaire_responses_0_choices_2_custom_body", with: "Cacatua") + end + + it_behaves_like "question has a character limit" + end + + context "when question is multiple_option type" do + let(:question_type) { "multiple_option" } + + it "renders them as check boxes with attached text fields disabled by default" do + expect(page.first(".check-box-collection")).to have_selector("input[type=checkbox]", count: 3) + + expect(page).to have_field("questionnaire_responses_0_choices_2_custom_body", disabled: true, count: 1) + + check answer_option_bodies[2]["en"] + + expect(page).to have_field("questionnaire_responses_0_choices_2_custom_body", disabled: false, count: 1) + end + + it "saves the free text in a separate field if submission correct" do + check answer_option_bodies[2]["en"] + fill_in "questionnaire_responses_0_choices_2_custom_body", with: "Cacatua" + + check "questionnaire_tos_agreement" + accept_confirm { click_button "Submit" } + + within ".success.flash" do + expect(page).to have_content("successfully") + end + + expect(Decidim::Forms::Answer.first.choices.first.custom_body).to eq("Cacatua") + end + + it "preserves the previous custom body if submission not correct" do + check "questionnaire_responses_1_choices_0_body" + check "questionnaire_responses_1_choices_1_body" + check "questionnaire_responses_1_choices_2_body" + + check answer_option_bodies[2]["en"] + fill_in "questionnaire_responses_0_choices_2_custom_body", with: "Cacatua" + + check "questionnaire_tos_agreement" + accept_confirm { click_button "Submit" } + + within ".alert.flash" do + expect(page).to have_content("There was a problem answering") + end + + expect(page).to have_field("questionnaire_responses_0_choices_2_custom_body", with: "Cacatua") + end + + it_behaves_like "question has a character limit" + end + end + + context "when question type is long answer" do + let(:max_characters) { 0 } + let!(:question) { create(:questionnaire_question, questionnaire: questionnaire, question_type: "long_answer", max_characters: max_characters) } + + it "renders the answer as a textarea" do + visit questionnaire_public_path + + expect(page).to have_selector("textarea#questionnaire_responses_0") + end + + it_behaves_like "question has a character limit" + end + + context "when question type is short answer" do + let(:max_characters) { 0 } + let!(:question) { create(:questionnaire_question, questionnaire: questionnaire, question_type: "short_answer", max_characters: max_characters) } + + it "renders the answer as a text field" do + visit questionnaire_public_path + + expect(page).to have_selector("input[type=text]#questionnaire_responses_0") + end + + it_behaves_like "question has a character limit" + end + + context "when question type is single option" do + let(:answer_options) { Array.new(2) { { "body" => Decidim::Faker::Localized.sentence } } } + let!(:question) { create(:questionnaire_question, questionnaire: questionnaire, question_type: "single_option", options: answer_options) } + + it "renders answers as a collection of radio buttons" do + visit questionnaire_public_path + + expect(page).to have_selector(".radio-button-collection input[type=radio]", count: 2) + + choose answer_options[0]["body"][:en] + + check "questionnaire_tos_agreement" + + accept_confirm { click_button "Submit" } + + within ".success.flash" do + expect(page).to have_content("successfully") + end + + visit questionnaire_public_path + + expect(page).to have_content("You have already answered this form.") + expect(page).to have_no_i18n_content(question.body) + end + end + + context "when question type is multiple option" do + let(:answer_options) { Array.new(3) { { "body" => Decidim::Faker::Localized.sentence } } } + let!(:question) { create(:questionnaire_question, questionnaire: questionnaire, question_type: "multiple_option", options: answer_options) } + + it "renders answers as a collection of radio buttons" do + visit questionnaire_public_path + + expect(page).to have_selector(".check-box-collection input[type=checkbox]", count: 3) + + expect(page).to have_no_content("Max choices:") + + check answer_options[0]["body"][:en] + check answer_options[1]["body"][:en] + + check "questionnaire_tos_agreement" + + accept_confirm { click_button "Submit" } + + within ".success.flash" do + expect(page).to have_content("successfully") + end + + visit questionnaire_public_path + + expect(page).to have_content("You have already answered this form.") + expect(page).to have_no_i18n_content(question.body) + end + + it "respects the max number of choices" do + question.update!(max_choices: 2) + + visit questionnaire_public_path + + expect(page).to have_content("Max choices: 2") + + check answer_options[0]["body"][:en] + check answer_options[1]["body"][:en] + check answer_options[2]["body"][:en] + + expect(page).to have_content("too many choices") + + check "questionnaire_tos_agreement" + + accept_confirm { click_button "Submit" } + + within ".alert.flash" do + expect(page).to have_content("There was a problem answering") + end + + expect(page).to have_content("are too many") + + uncheck answer_options[2]["body"][:en] + + accept_confirm { click_button "Submit" } + + within ".success.flash" do + expect(page).to have_content("successfully") + end + end + end + + context "when question type is sorting" do + let!(:question) do + create( + :questionnaire_question, + questionnaire: questionnaire, + question_type: "sorting", + options: [ + { "body" => { "en" => "chocolate" } }, + { "body" => { "en" => "like" } }, + { "body" => { "en" => "We" } }, + { "body" => { "en" => "dark" } }, + { "body" => { "en" => "all" } } + ] + ) + end + + it "renders the question answers as a collection of check boxes sortable on click" do + visit questionnaire_public_path + + expect(page).to have_selector(".sortable-check-box-collection input[type=checkbox]", count: 5) + + expect(page).to have_content("chocolate\nlike\nWe\ndark\nall") + + check "We" + check "all" + check "like" + check "dark" + check "chocolate" + + expect(page).to have_content("1. We\n2. all\n3. like\n4. dark\n5. chocolate") + end + + it "properly saves valid sortings" do + visit questionnaire_public_path + + check "We" + check "all" + check "like" + check "dark" + check "chocolate" + + check "questionnaire_tos_agreement" + + accept_confirm { click_button "Submit" } + + within ".success.flash" do + expect(page).to have_content("successfully") + end + + expect(Decidim::Forms::Answer.first.choices.pluck(:position, :body)).to eq( + [[0, "We"], [1, "all"], [2, "like"], [3, "dark"], [4, "chocolate"]] + ) + end + + it "displays errors on incomplete sortings" do + visit questionnaire_public_path + + check "We" + + accept_confirm { click_button "Submit" } + + within ".alert.flash" do + expect(page).to have_content("problem") + end + + expect(page).to have_content("are not complete") + end + + it "displays maintains sorting order if errors" do + visit questionnaire_public_path + + check "We" + check "dark" + check "chocolate" + + accept_confirm { click_button "Submit" } + + within ".alert.flash" do + expect(page).to have_content("problem") + end + + # Check the next round to ensure a re-submission conserves status + expect(page).to have_content("are not complete") + expect(page).to have_content("1. We\n2. dark\n3. chocolate\nlike\nall") + + checkboxes = page.all("input[type=checkbox]") + + checkboxes[0].uncheck + check "We" + check "all" + + accept_confirm { click_button "Submit" } + + within ".alert.flash" do + expect(page).to have_content("problem") + end + + expect(page).to have_content("are not complete") + expect(page).to have_content("1. dark\n2. chocolate\n3. We\n4. all\nlike") + end + end + + context "when question type is matrix_single" do + let(:matrix_rows) { Array.new(2) { { "body" => Decidim::Faker::Localized.sentence } } } + let(:answer_options) { Array.new(2) { { "body" => Decidim::Faker::Localized.sentence } } } + let(:mandatory) { false } + + let!(:question) do + create( + :questionnaire_question, + questionnaire: questionnaire, + question_type: "matrix_single", + rows: matrix_rows, + options: answer_options, + mandatory: mandatory + ) + end + + it "renders the question answers as a collection of radio buttons" do + visit questionnaire_public_path + + expect(page).to have_selector(".radio-button-collection input[type=radio]", count: 4) + + expect(page).to have_content(matrix_rows.map { |row| row["body"]["en"] }.join("\n")) + expect(page).to have_content(answer_options.map { |option| option["body"]["en"] }.join(" ")) + + radio_buttons = page.all(".radio-button-collection input[type=radio]") + + choose radio_buttons.first[:id] + choose radio_buttons.last[:id] + + check "questionnaire_tos_agreement" + + accept_confirm { click_button "Submit" } + + within ".success.flash" do + expect(page).to have_content("successfully") + end + + visit questionnaire_public_path + + expect(page).to have_content("You have already answered this form.") + expect(page).to have_no_i18n_content(question.body) + + first_choice, last_choice = Decidim::Forms::Answer.last.choices.pluck(:decidim_answer_option_id, :decidim_question_matrix_row_id) + + expect(first_choice).to eq([question.answer_options.first.id, question.matrix_rows.first.id]) + expect(last_choice).to eq([question.answer_options.last.id, question.matrix_rows.last.id]) + end + + it "preserves the chosen answers if submission not correct" do + visit questionnaire_public_path + + radio_buttons = page.all(".radio-button-collection input[type=radio]") + choose radio_buttons[1][:id] + + accept_confirm { click_button "Submit" } + + within ".alert.flash" do + expect(page).to have_content("There was a problem answering") + end + + radio_buttons = page.all(".radio-button-collection input[type=radio]") + expect(radio_buttons.pluck(:checked)).to eq([nil, "true", nil, nil]) + end + + context "when the question is mandatory and the answer is not complete" do + let!(:mandatory) { true } + + it "shows an error if the question is mandatory and the answer is not complete" do + visit questionnaire_public_path + + radio_buttons = page.all(".radio-button-collection input[type=radio]") + choose radio_buttons[0][:id] + + check "questionnaire_tos_agreement" + accept_confirm { click_button "Submit" } + + within ".alert.flash" do + expect(page).to have_content("There was a problem answering") + end + + expect(page).to have_content("Choices are not complete") + end + end + end + + context "when question type is matrix_multiple" do + let(:matrix_rows) { Array.new(2) { { "body" => Decidim::Faker::Localized.sentence } } } + let(:answer_options) { Array.new(3) { { "body" => Decidim::Faker::Localized.sentence } } } + let(:max_choices) { nil } + let(:mandatory) { false } + + let!(:question) do + create( + :questionnaire_question, + questionnaire: questionnaire, + question_type: "matrix_multiple", + rows: matrix_rows, + options: answer_options, + max_choices: max_choices, + mandatory: mandatory + ) + end + + it "renders the question answers as a collection of check boxes" do + visit questionnaire_public_path + + expect(page).to have_selector(".check-box-collection input[type=checkbox]", count: 6) + + expect(page).to have_content(matrix_rows.map { |row| row["body"]["en"] }.join("\n")) + expect(page).to have_content(answer_options.map { |option| option["body"]["en"] }.join(" ")) + + checkboxes = page.all(".check-box-collection input[type=checkbox]") + + check checkboxes[0][:id] + check checkboxes[1][:id] + check checkboxes[3][:id] + + check "questionnaire_tos_agreement" + + accept_confirm { click_button "Submit" } + + within ".success.flash" do + expect(page).to have_content("successfully") + end + + visit questionnaire_public_path + + expect(page).to have_content("You have already answered this form.") + expect(page).to have_no_i18n_content(question.body) + + first_choice, second_choice, third_choice = Decidim::Forms::Answer.last.choices.pluck(:decidim_answer_option_id, :decidim_question_matrix_row_id) + + expect(first_choice).to eq([question.answer_options.first.id, question.matrix_rows.first.id]) + expect(second_choice).to eq([question.answer_options.second.id, question.matrix_rows.first.id]) + expect(third_choice).to eq([question.answer_options.first.id, question.matrix_rows.last.id]) + end + + context "when the question hax max_choices defined" do + let!(:max_choices) { 2 } + + it "respects the max number of choices" do + visit questionnaire_public_path + + expect(page).to have_content("Max choices: 2") + + checkboxes = page.all(".check-box-collection input[type=checkbox]") + + check checkboxes[0][:id] + check checkboxes[1][:id] + check checkboxes[2][:id] + + expect(page).to have_content("too many choices") + + check checkboxes[3][:id] + check checkboxes[4][:id] + + expect(page).to have_content("too many choices") + + check checkboxes[5][:id] + + uncheck checkboxes[0][:id] + + expect(page).to have_content("too many choices") + + check "questionnaire_tos_agreement" + + accept_confirm { click_button "Submit" } + + within ".alert.flash" do + expect(page).to have_content("There was a problem answering") + end + + expect(page).to have_content("are too many") + + checkboxes = page.all(".check-box-collection input[type=checkbox]") + + uncheck checkboxes[5][:id] + + accept_confirm { click_button "Submit" } + + within ".success.flash" do + expect(page).to have_content("successfully") + end + end + end + + context "when the question is mandatory and the answer is not complete" do + let!(:mandatory) { true } + + it "shows an error" do + visit questionnaire_public_path + + checkboxes = page.all(".check-box-collection input[type=checkbox]") + check checkboxes[0][:id] + + check "questionnaire_tos_agreement" + accept_confirm { click_button "Submit" } + + within ".alert.flash" do + expect(page).to have_content("There was a problem answering") + end + + expect(page).to have_content("Choices are not complete") + end + end + + context "when the submission is not correct" do + let!(:max_choices) { 2 } + + it "preserves the chosen answers" do + visit questionnaire_public_path + + checkboxes = page.all(".check-box-collection input[type=checkbox]") + check checkboxes[0][:id] + check checkboxes[1][:id] + check checkboxes[2][:id] + check checkboxes[5][:id] + + check "questionnaire_tos_agreement" + accept_confirm { click_button "Submit" } + + within ".alert.flash" do + expect(page).to have_content("There was a problem answering") + end + + checkboxes = page.all(".check-box-collection input[type=checkbox]") + expect(checkboxes.pluck(:checked)).to eq(["true", "true", "true", nil, nil, "true"]) + end + end + end + + describe "display conditions" do + let(:answer_options) do + 3.times.to_a.map do |x| + { + "body" => Decidim::Faker::Localized.sentence, + "free_text" => x == 2 + } + end + end + let(:condition_question_options) { [] } + let!(:question) { create(:questionnaire_question, questionnaire: questionnaire, position: 2) } + let!(:conditioned_question_id) { "#questionnaire_responses_1" } + let!(:condition_question) do + create(:questionnaire_question, + questionnaire: questionnaire, + question_type: condition_question_type, + position: 1, + options: condition_question_options) + end + + context "when a question has a display condition" do + context "when condition is of type 'answered'" do + let!(:display_condition) do + create(:display_condition, + condition_type: "answered", + question: question, + condition_question: condition_question) + end + + before do + visit questionnaire_public_path + end + + context "when the condition_question type is short answer" do + let!(:condition_question_type) { "short_answer" } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(false) + + fill_in "questionnaire_responses_0", with: "Cacatua" + change_focus + + expect_question_to_be_visible(true) + + fill_in "questionnaire_responses_0", with: "" + change_focus + + expect_question_to_be_visible(false) + end + end + + context "when the condition_question type is long answer" do + let!(:condition_question_type) { "long_answer" } + let!(:conditioned_question_id) { "#questionnaire_responses_0" } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(false) + + fill_in "questionnaire_responses_0", with: "Cacatua" + change_focus + + expect_question_to_be_visible(true) + + fill_in "questionnaire_responses_0", with: "" + change_focus + + expect_question_to_be_visible(false) + end + end + + context "when the condition_question type is single option" do + let!(:condition_question_type) { "single_option" } + let!(:condition_question_options) { answer_options } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(false) + + choose condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(true) + + choose condition_question.answer_options.second.body["en"] + + expect_question_to_be_visible(false) + end + end + + context "when the condition_question type is multiple option" do + let!(:condition_question_type) { "multiple_option" } + let!(:condition_question_options) { answer_options } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(false) + + check condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(true) + + uncheck condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(false) + + check condition_question.answer_options.second.body["en"] + + expect_question_to_be_visible(false) + + check condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(true) + end + end + end + + context "when a question has a display condition of type 'not_answered'" do + let!(:display_condition) do + create(:display_condition, + condition_type: "not_answered", + question: question, + condition_question: condition_question) + end + + before do + visit questionnaire_public_path + end + + context "when the condition_question type is short answer" do + let!(:condition_question_type) { "short_answer" } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(true) + + fill_in "questionnaire_responses_0", with: "Cacatua" + change_focus + + expect_question_to_be_visible(false) + + fill_in "questionnaire_responses_0", with: "" + change_focus + + expect_question_to_be_visible(true) + end + end + + context "when the condition_question type is long answer" do + let!(:condition_question_type) { "long_answer" } + let!(:conditioned_question_id) { "#questionnaire_responses_0" } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(true) + + fill_in "questionnaire_responses_0", with: "Cacatua" + change_focus + + expect_question_to_be_visible(false) + + fill_in "questionnaire_responses_0", with: "" + change_focus + + expect_question_to_be_visible(true) + end + end + + context "when the condition_question type is single option" do + let!(:condition_question_type) { "single_option" } + let!(:condition_question_options) { answer_options } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(true) + + choose condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(false) + end + end + + context "when the condition_question type is multiple option" do + let!(:condition_question_type) { "multiple_option" } + let!(:condition_question_options) { answer_options } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(true) + + check condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(false) + + uncheck condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(true) + end + end + end + + context "when a question has a display condition of type 'equal'" do + let!(:display_condition) do + create(:display_condition, + condition_type: "equal", + question: question, + condition_question: condition_question, + answer_option: condition_question.answer_options.first) + end + + before do + visit questionnaire_public_path + end + + context "when the condition_question type is single option" do + let!(:condition_question_type) { "single_option" } + let!(:condition_question_options) { answer_options } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(false) + + choose condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(true) + + choose condition_question.answer_options.second.body["en"] + + expect_question_to_be_visible(false) + end + end + + context "when the condition_question type is multiple option" do + let!(:condition_question_type) { "multiple_option" } + let!(:condition_question_options) { answer_options } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(false) + + check condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(true) + + uncheck condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(false) + + check condition_question.answer_options.second.body["en"] + + expect_question_to_be_visible(false) + + check condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(true) + end + end + end + + context "when a question has a display condition of type 'not_equal'" do + let!(:display_condition) do + create(:display_condition, + condition_type: "not_equal", + question: question, + condition_question: condition_question, + answer_option: condition_question.answer_options.first) + end + + before do + visit questionnaire_public_path + end + + context "when the condition_question type is single option" do + let!(:condition_question_type) { "single_option" } + let!(:condition_question_options) { answer_options } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(false) + + choose condition_question.answer_options.second.body["en"] + + expect_question_to_be_visible(true) + + choose condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(false) + end + end + + context "when the condition_question type is multiple option" do + let!(:condition_question_type) { "multiple_option" } + let!(:condition_question_options) { answer_options } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(false) + + check condition_question.answer_options.second.body["en"] + + expect_question_to_be_visible(true) + + uncheck condition_question.answer_options.second.body["en"] + + expect_question_to_be_visible(false) + + check condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(false) + + check condition_question.answer_options.second.body["en"] + + expect_question_to_be_visible(true) + end + end + end + + context "when a question has a display condition of type 'match'" do + let!(:condition_value) { { en: "something" } } + let!(:display_condition) do + create(:display_condition, + condition_type: "match", + question: question, + condition_question: condition_question, + condition_value: condition_value) + end + + before do + visit questionnaire_public_path + end + + context "when the condition_question type is short answer" do + let!(:condition_question_type) { "short_answer" } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(false) + + fill_in "questionnaire_responses_0", with: "Aren't we all expecting #{condition_value[:en]}?" + change_focus + + expect_question_to_be_visible(true) + + fill_in "questionnaire_responses_0", with: "Now upcase #{condition_value[:en].upcase}!" + change_focus + + expect_question_to_be_visible(true) + + fill_in "questionnaire_responses_0", with: "Cacatua" + change_focus + + expect_question_to_be_visible(false) + end + end + + context "when the condition_question type is long answer" do + let!(:condition_question_type) { "long_answer" } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(false) + + fill_in "questionnaire_responses_0", with: "Aren't we all expecting #{condition_value[:en]}?" + change_focus + + expect_question_to_be_visible(true) + + fill_in "questionnaire_responses_0", with: "Now upcase #{condition_value[:en].upcase}!" + change_focus + + expect_question_to_be_visible(true) + + fill_in "questionnaire_responses_0", with: "Cacatua" + change_focus + + expect_question_to_be_visible(false) + end + end + + context "when the condition_question type is single option" do + let!(:condition_question_type) { "single_option" } + let!(:condition_question_options) { answer_options } + let!(:condition_value) { { en: condition_question.answer_options.first.body["en"].split.second.upcase } } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(false) + + choose condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(true) + end + end + + context "when the condition_question type is single option with free text" do + let!(:condition_question_type) { "single_option" } + let!(:condition_question_options) { answer_options } + let!(:condition_value) { { en: "forty two" } } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(false) + + choose condition_question.answer_options.third.body["en"] + fill_in "questionnaire_responses_0_choices_2_custom_body", with: "The answer is #{condition_value[:en]}" + change_focus + + expect_question_to_be_visible(true) + + choose condition_question.answer_options.first.body["en"] + expect_question_to_be_visible(false) + + choose condition_question.answer_options.third.body["en"] + fill_in "questionnaire_responses_0_choices_2_custom_body", with: "oh no not 42 again" + change_focus + + expect_question_to_be_visible(false) + end + end + + context "when the condition_question type is multiple option" do + let!(:condition_question_type) { "multiple_option" } + let!(:condition_question_options) { answer_options } + let!(:condition_value) { { en: "forty two" } } + + it "shows the question only if the condition is fulfilled" do + expect_question_to_be_visible(false) + + check condition_question.answer_options.third.body["en"] + fill_in "questionnaire_responses_0_choices_2_custom_body", with: "The answer is #{condition_value[:en]}" + change_focus + + expect_question_to_be_visible(true) + + check condition_question.answer_options.first.body["en"] + expect_question_to_be_visible(true) + + uncheck condition_question.answer_options.third.body["en"] + expect_question_to_be_visible(false) + + check condition_question.answer_options.third.body["en"] + fill_in "questionnaire_responses_0_choices_2_custom_body", with: "oh no not 42 again" + change_focus + + expect_question_to_be_visible(false) + end + end + end + end + + context "when a question has multiple display conditions" do + before do + visit questionnaire_public_path + end + + context "when all conditions are mandatory" do + let!(:condition_question_type) { "single_option" } + let!(:condition_question_options) { answer_options } + let!(:display_conditions) do + [ + create(:display_condition, + condition_type: "answered", + question: question, + condition_question: condition_question, + mandatory: true), + create(:display_condition, + condition_type: "not_equal", + question: question, + condition_question: condition_question, + mandatory: true, + answer_option: condition_question.answer_options.second) + ] + end + + it "is displayed only if all conditions are fulfilled" do + expect_question_to_be_visible(false) + + choose condition_question.answer_options.second.body["en"] + + expect_question_to_be_visible(false) + + choose condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(true) + end + end + + context "when all conditions are non-mandatory" do + let!(:condition_question_type) { "multiple_option" } + let!(:condition_question_options) { answer_options } + let!(:display_conditions) do + [ + create(:display_condition, + condition_type: "equal", + question: question, + condition_question: condition_question, + mandatory: false, + answer_option: condition_question.answer_options.first), + create(:display_condition, + condition_type: "not_equal", + question: question, + condition_question: condition_question, + mandatory: false, + answer_option: condition_question.answer_options.third) + ] + end + + it "is displayed if any of the conditions is fulfilled" do + expect_question_to_be_visible(false) + + check condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(true) + + uncheck condition_question.answer_options.first.body["en"] + check condition_question.answer_options.second.body["en"] + + expect_question_to_be_visible(true) + + check condition_question.answer_options.first.body["en"] + + expect_question_to_be_visible(true) + end + end + + context "when a mandatory question has conditions that have not been fulfilled" do + let!(:condition_question_type) { "short_answer" } + let!(:question) { create(:questionnaire_question, questionnaire: questionnaire, position: 2, mandatory: true) } + let!(:display_conditions) do + [ + create(:display_condition, + condition_type: "match", + question: question, + condition_question: condition_question, + condition_value: { en: "hey", es: "ey", ca: "ei" }, + mandatory: true) + ] + end + + it "doesn't throw error" do + visit questionnaire_public_path + + fill_in condition_question.body["en"], with: "My first answer" + + check "questionnaire_tos_agreement" + + accept_confirm { click_button "Submit" } + + within ".success.flash" do + expect(page).to have_content("successfully") + end + end + end + end + end + + private + + def expect_question_to_be_visible(visible) + expect(page).to have_css(conditioned_question_id, visible: visible) + end + + def change_focus + check "questionnaire_tos_agreement" + end + end +end diff --git a/spec/system/survey_spec.rb b/spec/system/survey_spec.rb new file mode 100644 index 0000000000..ebf0f5e6fe --- /dev/null +++ b/spec/system/survey_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "Answer a survey", type: :system do + let(:manifest_name) { "surveys" } + + let(:title) do + { + "en" => "Survey's title", + "ca" => "Títol de l'enquesta'", + "es" => "Título de la encuesta" + } + end + let(:description) do + { + "en" => "

Survey's content

", + "ca" => "

Contingut de l'enquesta

", + "es" => "

Contenido de la encuesta

" + } + end + let(:user) { create(:user, :confirmed, organization: component.organization) } + let!(:questionnaire) { create(:questionnaire, title: title, description: description) } + let!(:survey) { create(:survey, component: component, questionnaire: questionnaire) } + let!(:question) { create(:questionnaire_question, questionnaire: questionnaire, position: 0) } + + include_context "with a component" + + it_behaves_like "preview component with share_token" + + context "when the survey doesn't allow answers" do + it "does not allow answering the survey" do + visit_component + + expect(page).to have_i18n_content(questionnaire.title, upcase: true) + expect(page).to have_i18n_content(questionnaire.description) + + expect(page).to have_no_i18n_content(question.body) + + expect(page).to have_content("The form is closed and cannot be answered.") + end + end + + context "when the survey requires permissions to be answered" do + before do + permissions = { + answer: { + authorization_handlers: { + "dummy_authorization_handler" => { "options" => {} } + } + } + } + + component.update!(permissions: permissions) + visit_component + end + + it "shows a modal dialog" do + expect(page).to have_content("I fill in my phone number") + end + end + + context "when the survey allow answers" do + context "when the survey is closed by start and end dates" do + before do + component.update!(settings: { starts_at: 1.week.ago, ends_at: 1.day.ago }) + end + + it "does not allow answering the survey" do + visit_component + + expect(page).to have_i18n_content(questionnaire.title, upcase: true) + expect(page).to have_i18n_content(questionnaire.description) + + expect(page).to have_no_i18n_content(question.body) + + expect(page).to have_content("The form is closed and cannot be answered.") + end + end + + context "when the survey is open" do + before do + component.update!( + step_settings: { + component.participatory_space.active_step.id => { + allow_answers: true + } + }, + settings: { starts_at: 1.week.ago, ends_at: 1.day.from_now } + ) + end + + it_behaves_like "has questionnaire" + end + end + + context "when survey has action log entry" do + let!(:action_log) { create(:action_log, user: user, organization: component.organization, resource: survey, component: component, participatory_space: component.participatory_space, visibility: "all") } + let(:router) { Decidim::EngineRouter.main_proxy(component) } + + it "shows action log entry" do + page.visit decidim.profile_activity_path(nickname: user.nickname) + expect(page).to have_content("New survey at #{translated(survey.component.participatory_space.title)}") + expect(page).to have_link(translated(survey.questionnaire.title), href: router.survey_path(survey)) + end + end + + def questionnaire_public_path + main_component_path(component) + end +end diff --git a/yarn.lock b/yarn.lock index 2a85ca5434..9c3f1892c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5278,15 +5278,20 @@ dependencies: "jquery" ">=1.8.0 <4.0.0" +"jquery-validation@^1.19.5": + "integrity" "sha512-X2SmnPq1mRiDecVYL8edWx+yTBZDyC8ohWXFhXdtqFHgU9Wd4KHkvcbCoIZ0JaSaumzS8s2gXSkP8F7ivg/8ZQ==" + "resolved" "https://registry.npmjs.org/jquery-validation/-/jquery-validation-1.19.5.tgz" + "version" "1.19.5" + "jquery.autocomplete@1.2.0": "integrity" "sha512-aoJC3KVrPpRGaZBUo9UxhwznYmQ0UuYd+FfjP9RAKmyB+1T3OLIjuQImT8pKX6eKpBt1z9JmD48GiD2Dx303bA==" "resolved" "https://registry.npmjs.org/jquery.autocomplete/-/jquery.autocomplete-1.2.0.tgz" "version" "1.2.0" -"jquery@^3.2.1", "jquery@>=1.8.0 <4.0.0", "jquery@>=3.4.1", "jquery@>=3.6.0": - "integrity" "sha512-opJeO4nCucVnsjiXOE+/PcCgYw9Gwpvs/a6B1LL/lQhwWwpbVEVYDZ1FokFr8PRc7ghYlrFPuyHuiiDNTQxmcw==" - "resolved" "https://registry.npmjs.org/jquery/-/jquery-3.6.1.tgz" - "version" "3.6.1" +"jquery@^1.7 || ^2.0 || ^3.1", "jquery@^3.2.1", "jquery@^3.6.3", "jquery@>=1.8.0 <4.0.0", "jquery@>=3.4.1", "jquery@>=3.6.0": + "integrity" "sha512-bZ5Sy3YzKo9Fyc8wH2iIQK4JImJ6R0GWI9kL1/k7Z91ZBNgkRXE6U0JfHIizZbort8ZunhSI3jw9I6253ahKfg==" + "resolved" "https://registry.npmjs.org/jquery/-/jquery-3.6.3.tgz" + "version" "3.6.3" "js-tokens@^3.0.0 || ^4.0.0", "js-tokens@^4.0.0": "integrity" "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" From ff0d1c146db591573576255ec4e9d2649eb6712b Mon Sep 17 00:00:00 2001 From: Elie Gaboriau Date: Thu, 19 Jan 2023 21:51:23 +0100 Subject: [PATCH 2/8] linter --- config/initializers/decidim_verifications.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/initializers/decidim_verifications.rb b/config/initializers/decidim_verifications.rb index a04920f8da..9da6141419 100644 --- a/config/initializers/decidim_verifications.rb +++ b/config/initializers/decidim_verifications.rb @@ -19,7 +19,7 @@ # end # end -if Rails.env == "test" +if Rails.env.test? Decidim::Verifications.register_workflow(:dummy_authorization_handler) do |workflow| workflow.form = "DummyAuthorizationHandler" workflow.action_authorizer = "DummyAuthorizationHandler::DummyActionAuthorizer" From f51b6fd21c2948fbf0a3d9a62b3649c70321319b Mon Sep 17 00:00:00 2001 From: Elie Gaboriau Date: Thu, 19 Jan 2023 21:57:57 +0100 Subject: [PATCH 3/8] change publik branch --- Gemfile | 2 +- Gemfile.lock | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Gemfile b/Gemfile index 8a4aaa8b89..78c6c29ecc 100644 --- a/Gemfile +++ b/Gemfile @@ -19,7 +19,7 @@ gem "decidim-spam_detection", git: "https://github.com/OpenSourcePolitics/decidi gem "decidim-templates" gem "decidim-term_customizer", git: "https://github.com/mainio/decidim-module-term_customizer.git" gem "omniauth-france_connect", git: "https://github.com/OpenSourcePolitics/omniauth-france_connect" -gem "omniauth-publik", git: "https://github.com/OpenSourcePolitics/omniauth-publik", branch: "v0.0.9" +gem "omniauth-publik", git: "https://github.com/OpenSourcePolitics/omniauth-publik" gem "dotenv-rails" diff --git a/Gemfile.lock b/Gemfile.lock index e975b17e77..bb74415219 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -39,10 +39,9 @@ GIT GIT remote: https://github.com/OpenSourcePolitics/omniauth-publik - revision: 1e7c6ab77084bfcb0fc652f89e309f6c71e1bfd3 - branch: v0.0.9 + revision: ab703a565c402b773ce0025593554b329f603e5c specs: - omniauth-publik (0.0.9) + omniauth-publik (0.1.1) omniauth (~> 2.0) omniauth-oauth2 (>= 1.7.2, < 2.0) From caddc39573895a030627120427270a0a7e2ca838 Mon Sep 17 00:00:00 2001 From: Elie Gaboriau Date: Fri, 20 Jan 2023 11:26:40 +0100 Subject: [PATCH 4/8] update spec and js to not rely on aria label --- app/packs/src/decidim/decidim_application.js | 2 +- spec/shared/has_questionnaire.rb | 50 +++++++++++++++----- 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/app/packs/src/decidim/decidim_application.js b/app/packs/src/decidim/decidim_application.js index 63b5b156c8..d7ef4a9330 100644 --- a/app/packs/src/decidim/decidim_application.js +++ b/app/packs/src/decidim/decidim_application.js @@ -10,7 +10,7 @@ import "jquery-validation" $(() => { if($(".submit_survey").length) { $("body").on('DOMNodeInserted', '.confirm-reveal', function () { - $('.button[aria-label="Ok"]').on("mouseup", function () { + $(".confirm-reveal .button:first").on("mouseup", function () { $("form.answer-questionnaire").validate({ ignore: "thrhwrt", focusInvalid: false, diff --git a/spec/shared/has_questionnaire.rb b/spec/shared/has_questionnaire.rb index 9f330dee0a..f24034efb0 100644 --- a/spec/shared/has_questionnaire.rb +++ b/spec/shared/has_questionnaire.rb @@ -233,22 +233,44 @@ def answer_first_questionnaire end describe "leaving a blank question (with js)" do - include_context "when a non multiple choice question is mandatory" + context "when one non multiple choice question is mandatory" do + include_context "when a non multiple choice question is mandatory" + before do + click_link "Continue" + click_button "Submit" + click_link "OK" + end - before do - click_link "Continue" - click_button "Submit" - page.execute_script('$(".button[aria-label=\'Ok\']").trigger("mouseup")') - click_link "OK" + it "shows errors without submitting the form" do + expect(page).to have_no_selector ".alert.flash" + different_error = I18n.t("decidim.forms.questionnaires.answer.max_choices_alert") + expect(different_error).to eq("There are too many choices selected") + expect(page).not_to have_content(different_error) + + expect(page).to have_content("can't be blank") + end end - it "shows errors without submitting the form" do - expect(page).to have_no_selector ".alert.flash" - different_error = I18n.t("decidim.forms.questionnaires.answer.max_choices_alert") - expect(different_error).to eq("There are too many choices selected") - expect(page).not_to have_content(different_error) + context "when one non multiple choice question is mandatory" do + include_context "when a non multiple choice question is mandatory" + let!(:question2) { create(:questionnaire_question, questionnaire: questionnaire, position: 2, mandatory: true) } - expect(page).to have_content("can't be blank") + before do + visit questionnaire_public_path + click_link "Continue" + click_button "Submit" + page.execute_script('$(".confirm-reveal .button:first").trigger("mouseup")') + click_link "OK" + end + + it "shows errors without submitting the form" do + expect(page).to have_no_selector ".alert.flash" + different_error = I18n.t("decidim.forms.questionnaires.answer.max_choices_alert") + expect(different_error).to eq("There are too many choices selected") + expect(page).not_to have_content(different_error) + + expect(page).to have_content("can't be blank").twice + end end end @@ -266,12 +288,14 @@ def answer_first_questionnaire ] ) end + let!(:separator) { create(:questionnaire_question, questionnaire: questionnaire, position: 1, question_type: :separator) } + let!(:question2) { create(:questionnaire_question, questionnaire: questionnaire, position: 2) } before do visit questionnaire_public_path + click_link "Continue" check "questionnaire_tos_agreement" - accept_confirm { click_button "Submit" } end From a573d51384b05dfe7ddd295461601702b37198f2 Mon Sep 17 00:00:00 2001 From: Elie Gaboriau Date: Fri, 20 Jan 2023 12:06:41 +0100 Subject: [PATCH 5/8] remove english error labels and fix i18n spec --- app/packs/src/decidim/decidim_application.js | 3 ++- config/i18n-tasks.yml | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/packs/src/decidim/decidim_application.js b/app/packs/src/decidim/decidim_application.js index d7ef4a9330..e9f797ef12 100644 --- a/app/packs/src/decidim/decidim_application.js +++ b/app/packs/src/decidim/decidim_application.js @@ -13,11 +13,12 @@ $(() => { $(".confirm-reveal .button:first").on("mouseup", function () { $("form.answer-questionnaire").validate({ ignore: "thrhwrt", + errorPlacement: function (error, element) {}, focusInvalid: false, invalidHandler: function(form, validator) { $(".questionnaire-step").each(function () { - console.log($(this).removeClass("hide")) + $(this).removeClass("hide"); }); $(".next_survey").hide(); $(".back_survey").hide(); diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index a0f6c88cdd..6fda63fee4 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -89,6 +89,8 @@ ignore_missing: - layouts.decidim.header.user_menu - decidim.devise.shared.omniauth_buttons.or - devise.shared.links.sign_in_with_provider + - decidim.forms.step_navigation.* + - decidim.forms.questionnaires.show.* # Consider these keys used: ignore_unused: - faker.* From a68eec7b1fd811ec15ddf9d82a7da8fa8ce54f16 Mon Sep 17 00:00:00 2001 From: Elie Gaboriau Date: Fri, 20 Jan 2023 14:30:57 +0100 Subject: [PATCH 6/8] fix when error in non string field --- .../decidim/forms/application_helper.rb | 28 +++++++++++++++++++ .../forms/questionnaires/show.html.erb | 4 +-- 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 app/helpers/decidim/forms/application_helper.rb diff --git a/app/helpers/decidim/forms/application_helper.rb b/app/helpers/decidim/forms/application_helper.rb new file mode 100644 index 0000000000..af6f73a99e --- /dev/null +++ b/app/helpers/decidim/forms/application_helper.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Decidim + module Forms + # Custom helpers, scoped to the forms engine. + module ApplicationHelper + # Show cell for selected models + def show_represent_user_group? + model_name = questionnaire_for.model_name.element + + permited_models.include?(model_name) + end + alias show_public_participation? show_represent_user_group? + + def permited_models + %(meeting) + end + + def invalid?(responses) + bool = false + responses.each do |response| + bool ||= response.errors.any? + end + bool + end + end + end +end diff --git a/app/views/decidim/forms/questionnaires/show.html.erb b/app/views/decidim/forms/questionnaires/show.html.erb index 282b00c98a..8dfe542939 100644 --- a/app/views/decidim/forms/questionnaires/show.html.erb +++ b/app/views/decidim/forms/questionnaires/show.html.erb @@ -62,7 +62,7 @@ <% answer_idx = 0 %> <% cleaned_answer_idx = 0 %> <% @form.responses_by_step.each_with_index do |step_answers, step_index| %> -
" data-toggler=".hide"> +
" data-toggler=".hide"> <% if @form.total_steps > 1 %>

<%= t(".current_step", step: step_index + 1) %> @@ -120,7 +120,7 @@ total_steps: @form.total_steps, button_disabled: !current_participatory_space.can_participate?(current_user), form: form, - errors: @form.errors.any? + errors: invalid?(@form.responses) ) %>

<% end %> From c33e07ddff1d42b49ae914158d9d2dff2379037b Mon Sep 17 00:00:00 2001 From: Elie Gaboriau Date: Fri, 20 Jan 2023 17:22:41 +0100 Subject: [PATCH 7/8] use extends instead of override and refactor helper add overloads --- OVERLOADS.md | 8 +++ .../decidim/forms/step_navigation_cell.rb | 50 ------------------- .../decidim/forms/application_helper.rb | 28 ----------- config/initializers/extends.rb | 2 + .../forms/step_navigation_cell_extends.rb | 15 ++++++ .../forms/application_helper_extends.rb | 15 ++++++ 6 files changed, 40 insertions(+), 78 deletions(-) delete mode 100644 app/cells/decidim/forms/step_navigation_cell.rb delete mode 100644 app/helpers/decidim/forms/application_helper.rb create mode 100644 lib/extends/cells/decidim/forms/step_navigation_cell_extends.rb create mode 100644 lib/extends/cells/helpers/decidim/forms/application_helper_extends.rb diff --git a/OVERLOADS.md b/OVERLOADS.md index 92f1fd7421..32a5b9bf3d 100644 --- a/OVERLOADS.md +++ b/OVERLOADS.md @@ -166,3 +166,11 @@ end * `spec/mailers/decidim/budgets/vote_reminder_mailer_spec.rb` * `spec/services/decidim/budgets/order_reminder_generator_spec.rb` * `spec/system/admin_reminds_users_with_pending_orders_spec.rb` + +## Fix survey validation +* `app/cells/decidim/forms/step_navigation/show.erb` +* `app/packs/src/decidim/decidim_application.js` +* `app/views/decidim/forms/questionnaires/show.html.erb` +* `config/initializers/decidim_verifications.rb` +* `spec/shared/has_questionnaire.rb` +* `spec/system/survey_spec.rb` diff --git a/app/cells/decidim/forms/step_navigation_cell.rb b/app/cells/decidim/forms/step_navigation_cell.rb deleted file mode 100644 index 8865eba6ce..0000000000 --- a/app/cells/decidim/forms/step_navigation_cell.rb +++ /dev/null @@ -1,50 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Forms - # This cell renders the navigation of a questionnaire step. - class StepNavigationCell < Decidim::ViewModel - include Decidim::LayoutHelper - - def current_step_index - model - end - - def first_step? - current_step_index.zero? - end - - def last_step? - current_step_index + 1 == total_steps - end - - def total_steps - options[:total_steps] - end - - def form - options[:form] - end - - def button_disabled? - options[:button_disabled] - end - - def previous_step_dom_id - "step-#{current_step_index - 1}" - end - - def next_step_dom_id - "step-#{current_step_index + 1}" - end - - def current_step_dom_id - "step-#{current_step_index}" - end - - def errors - options[:errors] - end - end - end -end diff --git a/app/helpers/decidim/forms/application_helper.rb b/app/helpers/decidim/forms/application_helper.rb deleted file mode 100644 index af6f73a99e..0000000000 --- a/app/helpers/decidim/forms/application_helper.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Decidim - module Forms - # Custom helpers, scoped to the forms engine. - module ApplicationHelper - # Show cell for selected models - def show_represent_user_group? - model_name = questionnaire_for.model_name.element - - permited_models.include?(model_name) - end - alias show_public_participation? show_represent_user_group? - - def permited_models - %(meeting) - end - - def invalid?(responses) - bool = false - responses.each do |response| - bool ||= response.errors.any? - end - bool - end - end - end -end diff --git a/config/initializers/extends.rb b/config/initializers/extends.rb index 6ec0b405ed..6842d3856d 100644 --- a/config/initializers/extends.rb +++ b/config/initializers/extends.rb @@ -2,3 +2,5 @@ require "extends/controllers/decidim/devise/account_controller_extends" require "extends/controllers/decidim/meetings/meetings_controller_extends" +require "extends/cells/decidim/forms/step_navigation_cell_extends" +require "extends/helpers/decidim/forms/application_helper_extends" diff --git a/lib/extends/cells/decidim/forms/step_navigation_cell_extends.rb b/lib/extends/cells/decidim/forms/step_navigation_cell_extends.rb new file mode 100644 index 0000000000..d41fa52852 --- /dev/null +++ b/lib/extends/cells/decidim/forms/step_navigation_cell_extends.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Decidim + module Forms + module StepNavigationCellExtends + def errors + options[:errors] + end + end + end +end + +Decidim::Forms::StepNavigationCell.class_eval do + prepend(Decidim::Forms::StepNavigationCellExtends) +end diff --git a/lib/extends/cells/helpers/decidim/forms/application_helper_extends.rb b/lib/extends/cells/helpers/decidim/forms/application_helper_extends.rb new file mode 100644 index 0000000000..ace1678df8 --- /dev/null +++ b/lib/extends/cells/helpers/decidim/forms/application_helper_extends.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Decidim + module Forms + class ApplicationHelperExtends + def invalid?(responses) + responses.map { |response| response.errors.any? }.any? + end + end + end +end + +Decidim::Forms::ApplicationHelper.class_eval do + prepend(Decidim::Forms::ApplicationHelperExtends) +end From a2a83f8d60207d54ae03501e0dd22436553d8896 Mon Sep 17 00:00:00 2001 From: Elie Gaboriau Date: Fri, 20 Jan 2023 17:37:44 +0100 Subject: [PATCH 8/8] move helper --- .../helpers/decidim/forms/application_helper_extends.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename lib/extends/{cells => }/helpers/decidim/forms/application_helper_extends.rb (73%) diff --git a/lib/extends/cells/helpers/decidim/forms/application_helper_extends.rb b/lib/extends/helpers/decidim/forms/application_helper_extends.rb similarity index 73% rename from lib/extends/cells/helpers/decidim/forms/application_helper_extends.rb rename to lib/extends/helpers/decidim/forms/application_helper_extends.rb index ace1678df8..5dd37474e5 100644 --- a/lib/extends/cells/helpers/decidim/forms/application_helper_extends.rb +++ b/lib/extends/helpers/decidim/forms/application_helper_extends.rb @@ -2,7 +2,7 @@ module Decidim module Forms - class ApplicationHelperExtends + module ApplicationHelperExtends def invalid?(responses) responses.map { |response| response.errors.any? }.any? end @@ -10,6 +10,6 @@ def invalid?(responses) end end -Decidim::Forms::ApplicationHelper.class_eval do +Decidim::Forms::ApplicationHelper.module_eval do prepend(Decidim::Forms::ApplicationHelperExtends) end