diff --git a/Gemfile.lock b/Gemfile.lock index ebfeed2b9..be5895b16 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -107,28 +107,28 @@ GEM aws-sdk-ses (~> 1, >= 1.50.0) aws-sdk-sesv2 (~> 1, >= 1.34.0) aws-eventstream (1.3.2) - aws-partitions (1.1090.0) - aws-sdk-core (3.222.2) + aws-partitions (1.1096.0) + aws-sdk-core (3.223.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) base64 jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.99.0) + aws-sdk-kms (1.100.0) aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) aws-sdk-rails (5.1.0) aws-sdk-core (~> 3) railties (>= 7.1.0) - aws-sdk-s3 (1.183.0) + aws-sdk-s3 (1.185.0) aws-sdk-core (~> 3, >= 3.216.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sdk-ses (1.82.0) + aws-sdk-ses (1.83.0) aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) - aws-sdk-sesv2 (1.74.0) + aws-sdk-sesv2 (1.75.0) aws-sdk-core (~> 3, >= 3.216.0) aws-sigv4 (~> 1.5) aws-sigv4 (1.11.0) @@ -186,7 +186,7 @@ GEM coercible (1.0.0) descendants_tracker (~> 0.0.1) concurrent-ruby (1.3.5) - connection_pool (2.5.1) + connection_pool (2.5.3) crass (1.0.6) csv (3.3.4) database_cleaner (2.1.0) @@ -219,7 +219,7 @@ GEM railties (>= 5.0.0) faker (3.5.1) i18n (>= 1.8.11, < 2) - faraday (2.13.0) + faraday (2.13.1) faraday-net_http (>= 2.0, < 3.5) json logger @@ -280,7 +280,7 @@ GEM rails-dom-testing (>= 1, < 3) railties (>= 4.2.0) thor (>= 0.14, < 2.0) - json (2.10.2) + json (2.11.3) json-jwt (1.16.7) activesupport (>= 4.2) aes_key_wrap @@ -327,7 +327,7 @@ GEM mime-types (3.6.2) logger mime-types-data (~> 3.2015) - mime-types-data (3.2025.0415) + mime-types-data (3.2025.0429) mini_magick (5.2.0) benchmark logger @@ -336,11 +336,11 @@ GEM minitest (5.25.5) msgpack (1.8.0) multi_json (1.15.0) - multi_xml (0.7.1) + multi_xml (0.7.2) bigdecimal (~> 3.1) net-http (0.6.0) uri - net-imap (0.5.7) + net-imap (0.5.8) date net-protocol net-pop (0.1.2) @@ -349,7 +349,7 @@ GEM timeout net-smtp (0.5.1) net-protocol - newrelic_rpm (9.18.0) + newrelic_rpm (9.19.0) nio4r (2.7.4) nokogiri (1.18.8) mini_portile2 (~> 2.8.2) @@ -407,14 +407,14 @@ GEM pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) - psych (5.2.3) + psych (5.2.4) date stringio - public_suffix (6.0.1) + public_suffix (6.0.2) puma (6.6.0) nio4r (~> 2.0) racc (1.8.1) - rack (3.1.13) + rack (3.1.14) rack-attack (6.7.0) rack (>= 1.0, < 4) rack-cors (2.0.2) @@ -423,7 +423,7 @@ GEM base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) - rack-session (2.1.0) + rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) rack-test (2.2.0) @@ -493,24 +493,24 @@ GEM rolify (6.0.1) rspec-core (3.13.3) rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) + rspec-expectations (3.13.4) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.2) + rspec-mocks (3.13.3) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (7.1.1) - actionpack (>= 7.0) - activesupport (>= 7.0) - railties (>= 7.0) + rspec-rails (8.0.0) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) rspec-core (~> 3.13) rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-support (3.13.2) + rspec-support (3.13.3) rspec_junit_formatter (0.6.0) rspec-core (>= 2, < 4, != 2.12.0) - rubocop (1.75.3) + rubocop (1.75.4) json (~> 2.3) language_server-protocol (~> 3.17.0.2) lint_roller (~> 1.1.0) @@ -555,7 +555,7 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 3.0) websocket (~> 1.0) - sidekiq (8.0.2) + sidekiq (8.0.3) connection_pool (>= 2.5.0) json (>= 2.9.0) logger (>= 1.6.2) diff --git a/app/controllers/admin/forms_controller.rb b/app/controllers/admin/forms_controller.rb index 06b6fc151..36c6d4193 100644 --- a/app/controllers/admin/forms_controller.rb +++ b/app/controllers/admin/forms_controller.rb @@ -218,6 +218,7 @@ def permissions def questions @form.warn_about_not_too_many_questions @form.ensure_a11_v2_format if @form.kind == "a11_v2" + @form.ensure_a11_v2_radio_format if @form.kind == "a11_v2_radio" ensure_form_manager(form: @form) unless @form.template? @questions = @form.ordered_questions end diff --git a/app/models/form.rb b/app/models/form.rb index c624d1d57..c9bbe5d03 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -57,7 +57,8 @@ def self.filtered_forms(user, aasm_state) def self.kinds [ "a11", - "a11_v2", # launched fall 2023 + "a11_v2", # launched Fall 2023 + "a11_v2_radio", # launched May 2025 "a11_yes_no", "open_ended", "other", # TODO: deprecate in favor of custom, @@ -289,7 +290,7 @@ def self.send_inactive_form_emails_since(days_ago) def self.find_inactive_forms_since(days_ago) min_time = Time.now - days_ago.days max_time = Time.now - (days_ago - 1).days - Form.published.where("last_response_created_at BETWEEN ? AND ?", min_time, max_time) + Form.non_templates.published.where("last_response_created_at BETWEEN ? AND ?", min_time, max_time) end def deployable_form? @@ -734,6 +735,41 @@ def ensure_a11_v2_format end end + def ensure_a11_v2_radio_format + question_1 = self.ordered_questions.find { |q| q.answer_field == "answer_01" } + if question_1.question_type != 'radio_buttons' + errors.add(:base, "The question for `answer_01` must be a Radio Buttons component with 5 options, with values 1-5") + end + + # ensure the form has the 4 required questions + required_elements = ["answer_01", "answer_02", "answer_03", "answer_04"] + unless contains_elements?(questions.collect(&:answer_field), required_elements) + errors.add(:base, "The A-11 v2 form must have questions for #{required_elements.to_sentence}") + end + + # ensure the positive indicators include ease and effectiveness + question_2 = self.ordered_questions.find { |q| q.answer_field == "answer_02" } + question_options = question_2.question_options + + question_option_values = question_options.collect(&:value) + required_options = ["effectiveness", "ease"] + missing_options = required_options - question_option_values + if missing_options.any? + errors.add(:base, "The question options for Question 2 must include: #{missing_options.join(', ')}") + end + + # ensure the positive indicators include ease and effectiveness + question_3 = self.ordered_questions.find { |q| q.answer_field == "answer_03" } + question_options = question_3.question_options + + question_option_values = question_options.collect(&:value) + required_options = ["effectiveness", "ease"] + missing_options = required_options - question_option_values + if missing_options.any? + errors.add(:base, "The question options for Question 3 must include: #{missing_options.join(', ')}") + end + end + def warn_about_not_too_many_questions if questions.size > 12 errors.add(:base, "Touchpoints supports a maximum of 20 questions. There are currently #{questions_count} questions. Fewer questions tend to yield higher response rates.") diff --git a/app/views/components/_form_a11_v2_radio_script.html.erb b/app/views/components/_form_a11_v2_radio_script.html.erb new file mode 100644 index 000000000..70efc876d --- /dev/null +++ b/app/views/components/_form_a11_v2_radio_script.html.erb @@ -0,0 +1,52 @@ +// Assumes: 4 questions: +// 1. 5 radio buttons with values 1-5 +// 2. positive checkbox indicators +// 3. negative checkbox indicators +// 4. open text +// Hides the 2nd and 3rd questions to start +// reveals 2 when selecting thumbs up +// reveals 3 when selecting thumbs down +<% + question_1 = form.ordered_questions.find { |q| q.answer_field == "answer_01"} + question_2 = form.ordered_questions.find { |q| q.answer_field == "answer_02"} + question_3 = form.ordered_questions.find { |q| q.answer_field == "answer_03"} +%> + +document.addEventListener('onTouchpointsFormLoaded', function(e) { + const formElement = e.detail.formComponent.formElement(); + const q2_container = formElement.querySelector("#<%= dom_id(question_2) %>"); + const q3_container = formElement.querySelector("#<%= dom_id(question_3) %>"); + + function hideQ2() { + q2_container.style.display = 'none'; + } + function showQ2() { + q2_container.style.display = 'block'; + } + + function hideQ3() { + q3_container.style.display = 'none'; + } + function showQ3() { + q3_container.style.display = 'block'; + } + + function showAndHideQuestions(selectedOption) { + if (selectedOption === "1" || selectedOption === "2" || selectedOption === "3") { + hideQ2() + showQ3() + } else if (selectedOption === "4" || selectedOption === "5") { + showQ2() + hideQ3() + } + } + + formElement.querySelectorAll('input[name="<%= question_1.ui_selector %>"]').forEach((radio) => { + radio.addEventListener('change', (event) => { + showAndHideQuestions(event.target.value); + }); + }); + + hideQ2() + hideQ3() +}) \ No newline at end of file diff --git a/app/views/components/forms/question_types/_radio_buttons.html.erb b/app/views/components/forms/question_types/_radio_buttons.html.erb index 463a74f1c..bf61d2a12 100644 --- a/app/views/components/forms/question_types/_radio_buttons.html.erb +++ b/app/views/components/forms/question_types/_radio_buttons.html.erb @@ -3,16 +3,22 @@
<% question.question_options.each_with_index do |option, index| %> <% @option_id = dom_id(option) %> -
aria-describedby="<%= "question-id-#{question.id}-help-text" %>" <% end %> > - <%= radio_button_tag(@option_id, option.value, nil, { id: @option_id, name: question.ui_selector, class: "usa-radio__input usa-radio__input--tile", required: question.is_required }) %> - <%= label_tag(@option_id, nil, class: "usa-radio__label") do %><%= option.text %><% end %> + <%= radio_button_tag(question.ui_selector, option[:value], nil, { + id: @option_id, + class: "usa-radio__input usa-radio__input--tile", + required: question.is_required + }) %> + <%= label_tag(@option_id, option.text, class: "usa-radio__label") %> <%- if option.other_option %> -
+
<%= label_tag(nil, for: "#{question.ui_selector}_other", class: "usa-input__label") do %><%= t 'form.enter_other_text' %><% end %> = { 'css' : "<%= escape_javascript(render partial: 'components/widget/widget', formats: :css, locals: { form: form }) %>", 'loadCSS' : <%= form.load_css %>, 'formSpecificScript' : function() { - <%- if ["a11_v2", "a11_yes_no"].include?(form.kind) %> + <%- if ["a11_v2", "a11_v2_radio", "a11_yes_no"].include?(form.kind) %> <%= render "components/form_#{form.kind}_script", form: form %> <% end %> }, diff --git a/spec/factories/form.rb b/spec/factories/form.rb index 8c68c2110..380cdf6f7 100644 --- a/spec/factories/form.rb +++ b/spec/factories/form.rb @@ -501,6 +501,7 @@ form_section: f.form_sections.first, text: 'Please rate your experience as a customer of Agency of Departments.', position: 1, + is_required: true, ) FactoryBot.create(:question, :with_a11_v2_checkbox_options, @@ -514,8 +515,47 @@ :with_a11_v2_checkbox_options, form: f, answer_field: :answer_03, + form_section: f.form_sections.first, + text: 'Negative indicators', + position: 3 + ) + FactoryBot.create(:question, + form: f, + answer_field: :answer_04, question_type: 'textarea', form_section: f.form_sections.first, + text: 'Additional comments', + position: 4 + ) + end + end + + trait :a11_v2_radio do + name { 'Version 2 of the A11 form (Radio Buttons)' } + kind { 'a11_v2_radio' } + after(:create) do |f, _evaluator| + question_1_radio_buttons = FactoryBot.create(:question, + form: f, + answer_field: :answer_01, + question_type: 'radio_buttons', + form_section: f.form_sections.first, + text: 'Please rate your experience as a customer of Agency of Departments.', + position: 1, + is_required: true, + ) + FactoryBot.create(:question, + :with_a11_v2_checkbox_options, + form: f, + answer_field: :answer_02, + form_section: f.form_sections.first, + text: 'Positive indicators', + position: 2, + ) + FactoryBot.create(:question, + :with_a11_v2_checkbox_options, + form: f, + answer_field: :answer_03, + form_section: f.form_sections.first, text: 'Negative indicators', position: 3 ) @@ -527,6 +567,37 @@ text: 'Additional comments', position: 4 ) + + QuestionOption.create!({ + question: question_1_radio_buttons, + text: 'Strongly disagree', + value: 1, + position: 1, + }) + QuestionOption.create!({ + question: question_1_radio_buttons, + text: 'Disagree', + value: 2, + position: 2, + }) + QuestionOption.create!({ + question: question_1_radio_buttons, + text: 'Neutral', + value: 3, + position: 3, + }) + QuestionOption.create!({ + question: question_1_radio_buttons, + text: 'Agree', + value: 4, + position: 4, + }) + QuestionOption.create!({ + question: question_1_radio_buttons, + text: 'Strongly agree', + value: 5, + position: 5, + }) end end diff --git a/spec/features/touchpoints_spec.rb b/spec/features/touchpoints_spec.rb index de21f0ce7..31bb0b1af 100644 --- a/spec/features/touchpoints_spec.rb +++ b/spec/features/touchpoints_spec.rb @@ -520,7 +520,7 @@ end end - describe 'A-11 Version 2 Form' do + describe 'A-11 Version 2 Form (Thumbs up/down)' do let!(:a11_v2_form) { FactoryBot.create(:form, :a11_v2, organization:) } before do @@ -528,7 +528,7 @@ end it 'submits successfully' do - expect(page).to have_content(form.title) + expect(page).to have_content(a11_v2_form.title) expect(page).to have_content("This is help text.") find("svg[aria-labelledby='thumbs-up-icon']").click # the thumbs up expect(page).to have_content("Positive indicators") @@ -544,7 +544,51 @@ latest_submission = Submission.ordered.first expect(latest_submission.answer_01).to eq '1' expect(latest_submission.answer_02).to eq 'effectiveness,transparency' - expect(latest_submission.answer_03).to eq "" + expect(latest_submission.answer_03).to eq nil + expect(latest_submission.answer_04).to eq "" + end + end + + describe 'A-11 Version 2 (Radio Button) Form' do + let!(:a11_v2_radio_form) { FactoryBot.create(:form, :a11_v2_radio, organization:) } + + before do + visit submit_touchpoint_path(a11_v2_radio_form) + end + + it 'toggles positive and negative indicators and submits successfully' do + expect(page).to have_content(a11_v2_radio_form.title) + expect(page).to_not have_content("Negative indicators") + expect(page).to_not have_content("Positive indicators") + + find_all("label")[0].click # option 1 + expect(page).to have_content("Negative indicators") + + find_all("label")[3].click # option 4 + expect(page).to have_content("Positive indicators") + + find_all("label")[1].click # option 2 + expect(page).to have_content("Negative indicators") + + find_all("label")[4].click # option 5 + expect(page).to have_content("Positive indicators") + + find_all("label")[2].click # option 3 + expect(page).to have_content("Negative indicators") + + expect(page).to have_content("This is help text.") + expect(page).to have_content("effectiveness") + expect(page).to have_content("ease") + expect(page).to have_content("efficiency") + expect(page).to have_content("transparency") + + click_button 'Submit' + expect(page).to have_content('Thank you. Your feedback has been received.') + + latest_submission = Submission.ordered.first + expect(latest_submission.answer_01).to eq '3' + expect(latest_submission.answer_02).to eq nil + expect(latest_submission.answer_03).to eq nil expect(latest_submission.answer_04).to eq "" end end diff --git a/spec/models/form_spec.rb b/spec/models/form_spec.rb index a9ce907c3..9a0c42d5c 100644 --- a/spec/models/form_spec.rb +++ b/spec/models/form_spec.rb @@ -87,7 +87,7 @@ end it 'adds an error for kind' do - expect(form_with_invalid_kind.errors.messages[:kind]).to eq(['kind must be one of the following: a11, a11_v2, a11_yes_no, custom, open_ended, other, recruiter, yes_no']) + expect(form_with_invalid_kind.errors.messages[:kind]).to eq(['kind must be one of the following: a11, a11_v2, a11_v2_radio, a11_yes_no, custom, open_ended, other, recruiter, yes_no']) end end