diff --git a/OVERLOADS.md b/OVERLOADS.md index 9e19c7a757..b8b89204a4 100644 --- a/OVERLOADS.md +++ b/OVERLOADS.md @@ -170,3 +170,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/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/packs/src/decidim/decidim_application.js b/app/packs/src/decidim/decidim_application.js index 5d5dcf59f4..e9f797ef12 100644 --- a/app/packs/src/decidim/decidim_application.js +++ b/app/packs/src/decidim/decidim_application.js @@ -3,3 +3,38 @@ // Load images require.context("../../images", true) + +import $ from "jquery" +import "jquery-validation" + +$(() => { + if($(".submit_survey").length) { + $("body").on('DOMNodeInserted', '.confirm-reveal', function () { + $(".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 () { + $(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..8dfe542939 --- /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: invalid?(@form.responses) + ) %> +
+ <% 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/i18n-tasks.yml b/config/i18n-tasks.yml index 6070d6587e..1a69cd39e6 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -89,8 +89,11 @@ 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.* - decidim.account.destroy.success - decidim.account.destroy.error + # Consider these keys used: ignore_unused: - faker.* diff --git a/config/initializers/decidim_verifications.rb b/config/initializers/decidim_verifications.rb index 73e6b603dc..9da6141419 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/config/initializers/extends.rb b/config/initializers/extends.rb index 0154001374..e885dcec5a 100644 --- a/config/initializers/extends.rb +++ b/config/initializers/extends.rb @@ -2,4 +2,6 @@ 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" require "extends/cells/decidim/budgets/project_list_item_cell_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/helpers/decidim/forms/application_helper_extends.rb b/lib/extends/helpers/decidim/forms/application_helper_extends.rb new file mode 100644 index 0000000000..5dd37474e5 --- /dev/null +++ b/lib/extends/helpers/decidim/forms/application_helper_extends.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Decidim + module Forms + module ApplicationHelperExtends + def invalid?(responses) + responses.map { |response| response.errors.any? }.any? + end + end + end +end + +Decidim::Forms::ApplicationHelper.module_eval do + prepend(Decidim::Forms::ApplicationHelperExtends) +end diff --git a/package-lock.json b/package-lock.json index 3ff8c71bbc..2fe25af464 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,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", @@ -7903,9 +7905,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", @@ -7925,6 +7927,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", @@ -20135,9 +20145,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", @@ -20157,6 +20167,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 e183a49122..6dc7ddba91 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,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..f24034efb0 --- /dev/null +++ b/spec/shared/has_questionnaire.rb @@ -0,0 +1,1451 @@ +# 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 + 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 + + 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 + + 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) } + + 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 + + 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 + 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 + + 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 c66f0da89a..aea9f8a0bd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5283,15 +5283,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=="