diff --git a/.review_apps/ecs_task_definition.tf b/.review_apps/ecs_task_definition.tf index b019cea45..f8c6b8e97 100644 --- a/.review_apps/ecs_task_definition.tf +++ b/.review_apps/ecs_task_definition.tf @@ -46,7 +46,7 @@ locals { { name = "SETTINGS__FORMS_ENV", value = "review" }, { name = "SETTINGS__FORMS_RUNNER__URL", value = "https://${local.runner_review_app_hostname}" }, { name = "ALLOWED_HOST_PATTERNS", value = "localhost:3000" }, - { name = "SETTINGS__FEATURES__JSON_SUBMISSION_ENABLED", value = "true" } + { name = "SETTINGS__FEATURES__DESCRIBE_NONE_OF_THE_ABOVE_ENABLED", value = "true" } ] } diff --git a/app/components/question/selection_component/view.html.erb b/app/components/question/selection_component/view.html.erb index fbc420655..ea725b9e2 100644 --- a/app/components/question/selection_component/view.html.erb +++ b/app/components/question/selection_component/view.html.erb @@ -1,4 +1,4 @@ -<% if question.answer_settings.selection_options.count > 30 %> +<% if question.autocomplete_component? %> <%= render DfE::Autocomplete::View.new( form_builder, attribute_name: :selection, diff --git a/app/components/question/selection_component/view.rb b/app/components/question/selection_component/view.rb index e813bfb4d..eb25a543a 100644 --- a/app/components/question/selection_component/view.rb +++ b/app/components/question/selection_component/view.rb @@ -36,17 +36,37 @@ def divider def none_of_the_above_radio_button return nil unless question.is_optional? - option = form_builder.govuk_radio_button :selection, I18n.t("page.none_of_the_above"), label: { text: I18n.t("page.none_of_the_above") } + option = form_builder.govuk_radio_button :selection, + I18n.t("page.none_of_the_above"), + label: { text: I18n.t("page.none_of_the_above") }, + &method(:none_of_the_above_question_field) safe_join([divider, option]) end def none_of_the_above_checkbox return nil unless question.is_optional? - option = form_builder.govuk_check_box :selection, I18n.t("page.none_of_the_above"), exclusive: true, label: { text: I18n.t("page.none_of_the_above") } + option = form_builder.govuk_check_box :selection, + I18n.t("page.none_of_the_above"), + exclusive: true, + label: { text: I18n.t("page.none_of_the_above") }, + &method(:none_of_the_above_question_field) safe_join([divider, option]) end + def none_of_the_above_question_field + if question.has_none_of_the_above_question? + form_builder.govuk_text_field :none_of_the_above_answer, label: { text: none_of_the_above_question_text }, width: "three-quarters" + end + end + + def none_of_the_above_question_text + none_of_the_above_question = question.answer_settings.none_of_the_above_question + return none_of_the_above_question.question_text if none_of_the_above_question.is_optional != "true" + + "#{none_of_the_above_question.question_text} #{I18n.t('page.optional')}" + end + def radio_button_options question.answer_settings.selection_options.map.with_index do |option, index| form_builder.govuk_radio_button :selection, option.name, label: { text: option.name }, link_errors: index.zero? diff --git a/app/models/question/selection.rb b/app/models/question/selection.rb index e592c1861..891735a7c 100644 --- a/app/models/question/selection.rb +++ b/app/models/question/selection.rb @@ -1,9 +1,18 @@ module Question class Selection < QuestionBase attribute :selection + attribute :none_of_the_above_answer + + before_validation :clear_none_of_the_above_answer_if_not_selected + validates :selection, presence: true validate :selection, :validate_checkbox, if: :allow_multiple_answers? validate :selection, :validate_radio, unless: :allow_multiple_answers? + validates :none_of_the_above_answer, length: { maximum: 499 } + + with_options unless: :autocomplete_component? do + validates :none_of_the_above_answer, presence: true, if: :validate_none_of_the_above_answer_presence? + end def allow_multiple_answers? answer_settings.only_one_option != "true" @@ -42,8 +51,20 @@ def selection_options_with_none_of_the_above [*options, none_of_the_above_option] end + def autocomplete_component? + answer_settings.selection_options.count > 30 + end + + def has_none_of_the_above_question? + none_of_the_above_question.present? + end + private + def clear_none_of_the_above_answer_if_not_selected + self.none_of_the_above_answer = nil unless none_of_the_above_selected? + end + def allowed_options selection_options_with_none_of_the_above.map(&:name) end @@ -68,5 +89,23 @@ def validate_checkbox errors.add(:selection, :inclusion) if selection_without_blanks.any? { |item| allowed_options.exclude?(item) } end + + def validate_none_of_the_above_answer_presence? + none_of_the_above_question.present? && none_of_the_above_question.is_optional != "true" && none_of_the_above_selected? + end + + def none_of_the_above_question + return nil unless is_optional? + return nil unless answer_settings.respond_to?(:none_of_the_above_question) + return nil unless answer_settings.none_of_the_above_question.respond_to?(:question_text) + + answer_settings.none_of_the_above_question + end + + def none_of_the_above_selected? + return selection_without_blanks.include?(I18n.t("page.none_of_the_above")) if allow_multiple_answers? + + selection == I18n.t("page.none_of_the_above") + end end end diff --git a/config/locales/cy.yml b/config/locales/cy.yml index dffcc8121..99b0c3be4 100644 --- a/config/locales/cy.yml +++ b/config/locales/cy.yml @@ -174,6 +174,9 @@ cy: phone_too_short: Dylai’r rhif ffôn fod ag 8 neu fwy o ddigidau question/selection: attributes: + none_of_the_above_answer: + blank: Rhowch ateb + too_long: Rhaid i’r ateb fod yn llai na 500 o gymeriadau selection: blank: Dewiswch un opsiwn both_none_and_value_selected: Dewiswch un neu ragor o opsiynau neu dewiswch 'Dim un o’r uchod' diff --git a/config/locales/en.yml b/config/locales/en.yml index 1be1e2b77..e43cc77e9 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -174,6 +174,9 @@ en: phone_too_short: The phone number should have 8 digits or more question/selection: attributes: + none_of_the_above_answer: + blank: Enter an answer + too_long: The answer must be shorter than 500 characters selection: blank: Select one option both_none_and_value_selected: Select one or more options, or select ‘None of the above’ diff --git a/spec/components/question/selection_component/view_spec.rb b/spec/components/question/selection_component/view_spec.rb index c0610d572..c6a48a043 100644 --- a/spec/components/question/selection_component/view_spec.rb +++ b/spec/components/question/selection_component/view_spec.rb @@ -14,6 +14,42 @@ render_inline(described_class.new(form_builder:, question:, extra_question_text_suffix:)) end + shared_examples "None of the above question field" do + context "when a 'None of the above' question is not defined" do + it "does not render a conditional text field for the 'None of the above' option" do + expect(page).not_to have_css("input[type='text'][name='form[none_of_the_above_answer]']") + end + end + + context "when a 'None of the above' question is defined" do + let(:none_of_the_above_question_is_optional) { "true" } + let(:question) do + build(:single_selection_question, + :with_none_of_the_above_question, + none_of_the_above_question_text: "Enter another answer", + none_of_the_above_question_is_optional:) + end + + it "renders a conditional text field for the 'None of the above' option" do + expect(page).to have_css("input[type='text'][name='form[none_of_the_above_answer]']") + end + + context "when the 'None of the above' question is optional" do + it "has the question text with an optional suffix as the label for the field" do + expect(page).to have_css("label[for='form-none-of-the-above-answer-field']", text: "Enter another answer (optional)") + end + end + + context "when the 'None of the above' question is mandatory" do + let(:none_of_the_above_question_is_optional) { "false" } + + it "has the question text as the label for the field" do + expect(page).to have_css("label[for='form-none-of-the-above-answer-field']", text: "Enter another answer") + end + end + end + end + describe "when component is select one from a list field" do context "when there are 30 or fewer options" do let(:question) { build :single_selection_question, is_optional:, selection_options: } @@ -70,6 +106,8 @@ it "contains the 'None of the above' option" do expect(page).to have_css("input[type='radio'] + label", text: "None of the above") end + + include_examples "None of the above question field" end context "when question has guidance" do @@ -197,6 +235,8 @@ it "contains the 'None of the above' option" do expect(page).to have_css("input[type='checkbox'] + label", text: "None of the above") end + + include_examples "None of the above question field" end context "when question has guidance" do diff --git a/spec/factories/models/question/selection.rb b/spec/factories/models/question/selection.rb index 638febba6..b174b947b 100644 --- a/spec/factories/models/question/selection.rb +++ b/spec/factories/models/question/selection.rb @@ -3,6 +3,20 @@ question_text { Faker::Lorem.question } hint_text { nil } is_optional { false } + answer_settings do + if none_of_the_above_question + Struct.new(:only_one_option, :selection_options, :none_of_the_above_question) + .new(only_one_option, selection_options, none_of_the_above_question) + else + Struct.new(:only_one_option, :selection_options).new(only_one_option, selection_options) + end + end + + transient do + only_one_option { "true" } + selection_options { [DataStruct.new(name: "Option 1"), DataStruct.new(name: "Option 2")] } + none_of_the_above_question { nil } + end trait :with_hints do hint_text { Faker::Quote.yoda } @@ -13,18 +27,24 @@ guidance_markdown { "## List of items \n\n\n #{Faker::Markdown.ordered_list}" } end - factory :single_selection_question do + trait :with_none_of_the_above_question do transient do - selection_options { [DataStruct.new(name: "Option 1"), DataStruct.new(name: "Option 2")] } + none_of_the_above_question_text { Faker::Lorem.question } + none_of_the_above_question_is_optional { "false" } + end + is_optional { true } + none_of_the_above_question do + Struct.new(:question_text, :is_optional) + .new(none_of_the_above_question_text, none_of_the_above_question_is_optional) end - answer_settings { DataStruct.new(only_one_option: "true", selection_options:) } + end + + factory :single_selection_question do + only_one_option { "true" } end factory :multiple_selection_question do - transient do - selection_options { [DataStruct.new(name: "Option 1"), DataStruct.new(name: "Option 2")] } - end - answer_settings { DataStruct.new(only_one_option: "false", selection_options:) } + only_one_option { "false" } selection { ["Option 1", "Option 2"] } end end diff --git a/spec/models/question/selection_spec.rb b/spec/models/question/selection_spec.rb index 581d550ab..d82a260ea 100644 --- a/spec/models/question/selection_spec.rb +++ b/spec/models/question/selection_spec.rb @@ -1,18 +1,11 @@ require "rails_helper" RSpec.describe Question::Selection, type: :model do - subject(:question) { described_class.new({}, options) } - - let(:options) do - { - is_optional:, - answer_settings: OpenStruct.new({ - only_one_option:, - selection_options: [OpenStruct.new({ name: "option 1" }), OpenStruct.new({ name: "option 2" })], - }), - question_text:, - } - end + subject(:question) { build :selection, only_one_option:, selection_options:, is_optional:, question_text: } + + let(:selection_options) { [OpenStruct.new({ name: "option 1" }), OpenStruct.new({ name: "option 2" })] } + let(:is_optional) { false } + let(:only_one_option) { "false" } let(:question_text) { Faker::Lorem.question } context "when the selection question is a checkbox" do @@ -21,7 +14,7 @@ it_behaves_like "a question model" - context "when created without attriibutes" do + context "when created without attributes" do it "returns invalid" do expect(question).not_to be_valid expect(question.errors[:selection]).to include(I18n.t("activemodel.errors.models.question/selection.attributes.selection.checkbox_blank")) @@ -307,6 +300,134 @@ end end + context "when there is a none of the above question configured" do + subject(:question) do + build(:selection, :with_none_of_the_above_question, only_one_option:, selection_options:, is_optional:, + none_of_the_above_question_is_optional:) + end + + let(:is_optional) { true } + let(:none_of_the_above_question_is_optional) { "true" } + + context "when there fewer than 31 selection options" do + context "when only_one_option is false" do + let(:only_one_option) { "false" } + + context "when none_of_the_above_question is optional" do + context "when 'None of the above' is selected" do + before do + question.selection = [I18n.t("page.none_of_the_above")] + end + + it "is valid when there is no none_of_the_above_answer" do + expect(question).to be_valid + expect(question.errors[:none_of_the_above_answer]).to be_empty + end + + it "is invalid when the none_of_the_above answer is too long" do + question.none_of_the_above_answer = "a" * 500 + expect(question).not_to be_valid + expect(question.errors[:none_of_the_above_answer]).to include(I18n.t("activemodel.errors.models.question/selection.attributes.none_of_the_above_answer.too_long")) + end + end + + context "when 'None of the above' is not selected" do + it "clears the none_of_the_above_answer before validating" do + question.selection = ["option 1"] + question.none_of_the_above_answer = "Some answer" + expect(question).to be_valid + expect(question.none_of_the_above_answer).to be_nil + end + end + end + + context "when none_of_the_above_question is mandatory" do + let(:none_of_the_above_question_is_optional) { "false" } + + context "when 'None of the above' is selected" do + before do + question.selection = [I18n.t("page.none_of_the_above")] + end + + it "is invalid when there is no none_of_the_above_answer" do + expect(question).not_to be_valid + expect(question.errors[:none_of_the_above_answer]).to include(I18n.t("activemodel.errors.models.question/selection.attributes.none_of_the_above_answer.blank")) + end + + it "is valid when there is a none_of_the_above_answer" do + question.none_of_the_above_answer = "Some answer" + expect(question).to be_valid + expect(question.errors[:none_of_the_above_answer]).to be_empty + expect(question.none_of_the_above_answer).to eq("Some answer") + end + end + + context "when 'None of the above' is not selected" do + before do + question.selection = ["option 1"] + end + + it "is valid when there is no none_of_the_above_answer" do + expect(question).to be_valid + expect(question.errors[:none_of_the_above_answer]).to be_empty + end + + it "is valid when there is a none_of_the_above_answer that is too long" do + question.none_of_the_above_answer = "a" * 500 + expect(question).to be_valid + expect(question.errors[:none_of_the_above_answer]).to be_empty + end + + it "clears the none_of_the_above_answer before validating" do + question.none_of_the_above_answer = "Some answer" + expect(question).to be_valid + expect(question.none_of_the_above_answer).to be_nil + end + end + end + end + + context "when only_one_option is true" do + let(:only_one_option) { "true" } + let(:none_of_the_above_question_is_optional) { "false" } + + context "when 'None of the above' is selected" do + before do + question.selection = I18n.t("page.none_of_the_above") + end + + it "is invalid when there is no none_of_the_above_answer" do + expect(question).not_to be_valid + expect(question.errors[:none_of_the_above_answer]).to include(I18n.t("activemodel.errors.models.question/selection.attributes.none_of_the_above_answer.blank")) + end + + it "is invalid when the none_of_the_above answer is too long" do + question.none_of_the_above_answer = "a" * 500 + expect(question).not_to be_valid + expect(question.errors[:none_of_the_above_answer]).to include(I18n.t("activemodel.errors.models.question/selection.attributes.none_of_the_above_answer.too_long")) + end + end + + context "when 'None of the above' is not selected" do + before do + question.selection = "option 1" + end + + it "is valid when there is no none_of_the_above_answer" do + expect(question).to be_valid + expect(question.errors[:none_of_the_above_answer]).to be_empty + end + + it "is valid when there is a none_of_the_above_answer that is too long" do + question.none_of_the_above_answer = "a" * 500 + expect(question).to be_valid + expect(question.errors[:none_of_the_above_answer]).to be_empty + end + end + end + end + end + describe "#selection_options_with_none_of_the_above" do let(:only_one_option) { "true" } let(:none_of_the_above_option) { OpenStruct.new(name: I18n.t("page.none_of_the_above")) } @@ -315,7 +436,7 @@ let(:is_optional) { true } it "includes the selection options" do - question.answer_settings.each do |option| + question.answer_settings.selection_options.each do |option| expect(question.selection_options_with_none_of_the_above).to include(option) end end @@ -339,4 +460,67 @@ end end end + + describe "#autocomplete_component?" do + context "when there are 30 selection options" do + let(:selection_options) { Array.new(30).map { |_index| OpenStruct.new(name: Faker::Lorem.sentence) } } + + it "returns false" do + expect(question.autocomplete_component?).to be false + end + end + + context "when there are more than 30 selection options" do + let(:selection_options) { Array.new(31).map { |_index| OpenStruct.new(name: Faker::Lorem.sentence) } } + + it "returns true" do + expect(question.autocomplete_component?).to be true + end + end + end + + describe "#has_none_of_the_above_question?" do + let(:is_optional) { true } + + context "when there is a none of the above question configured" do + subject(:question) do + build(:selection, :with_none_of_the_above_question, only_one_option:, selection_options:, is_optional:, + none_of_the_above_question_is_optional:) + end + + let(:none_of_the_above_question_is_optional) { "true" } + + it "returns true" do + expect(question.has_none_of_the_above_question?).to be true + end + + context "when the question is not optional" do + let(:is_optional) { false } + + it "returns false" do + expect(question.has_none_of_the_above_question?).to be false + end + end + end + + context "when there is no none of the above question configured" do + it "returns false" do + expect(question.has_none_of_the_above_question?).to be false + end + end + + context "when the none_of_the_above_question has no question_text" do + subject(:question) do + build(:selection, + is_optional:, + only_one_option:, + selection_options:, + none_of_the_above_question: Struct.new) + end + + it "returns false" do + expect(question.has_none_of_the_above_question?).to be false + end + end + end end