From a328c16b9d1fa8556da6e3607e7127c52b6321c0 Mon Sep 17 00:00:00 2001 From: Tom Iles Date: Tue, 11 Mar 2025 13:54:22 +0000 Subject: [PATCH 1/4] Add exit_page route, controller, view To show exit pages to form fillers we need to add a route, controller and view. Exit pages are represented by conditions with exit_page_markdown set. For the route, we use the page slug and add /exit to the URL. This works because pages can only have one exit page at the moment. We might need to add condition id or make other changes when pages can have more than one exit page or when the exit page is the secondary route. The exit page controller inherits from page because it uses the current form, mode and step. We also redirect users back when they cannot view the current step because they haven't completed the form. The exit page is accessible as soon as the user can view the question step, regardless of the answer to the selection question. --- .../forms/exit_pages_controller.rb | 10 +++++ app/views/forms/exit_pages/show.html.erb | 13 +++++++ config/routes.rb | 4 ++ .../forms/exit_pages/show.html.erb_spec.rb | 38 +++++++++++++++++++ 4 files changed, 65 insertions(+) create mode 100644 app/controllers/forms/exit_pages_controller.rb create mode 100644 app/views/forms/exit_pages/show.html.erb create mode 100644 spec/views/forms/exit_pages/show.html.erb_spec.rb diff --git a/app/controllers/forms/exit_pages_controller.rb b/app/controllers/forms/exit_pages_controller.rb new file mode 100644 index 000000000..22f4f339f --- /dev/null +++ b/app/controllers/forms/exit_pages_controller.rb @@ -0,0 +1,10 @@ +module Forms + class ExitPagesController < PageController + def show + return redirect_to form_page_path(@step.form_id, @step.form_slug, current_context.next_page_slug) unless current_context.can_visit?(@step.page_slug) + + @back_link = form_page_path(@step.form_id, @step.form_slug, @step.page_slug) + @condition = @step.routing_conditions.first + end + end +end diff --git a/app/views/forms/exit_pages/show.html.erb b/app/views/forms/exit_pages/show.html.erb new file mode 100644 index 000000000..83f90ab4d --- /dev/null +++ b/app/views/forms/exit_pages/show.html.erb @@ -0,0 +1,13 @@ +<% set_page_title(form_title(form_name: @current_context.form.name, page_name: @condition.exit_page_heading, mode: @mode)) %> + +<% content_for :back_link do %> + <%= link_to "Back", @back_link, class: "govuk-back-link" %> +<% end %> + +
+
+

<%= @condition.exit_page_heading %>

+ <%= HtmlMarkdownSanitizer.new.render_scrubbed_markdown(@condition.exit_page_markdown) %> + <%= render SupportDetailsComponent::View.new(@support_details) %> +
+
diff --git a/config/routes.rb b/config/routes.rb index a4a20f12f..b340a2687 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -27,6 +27,10 @@ answer_constraints = { answer_index: /\d+/ } page_answer_defaults = { answer_index: 1 } + get "/:page_slug/exit" => "forms/exit_pages#show", + as: :exit_page, + constraints: page_constraints + get "/:page_slug/add-another-answer/change" => "forms/add_another_answer#show", as: :change_add_another_answer, constraints: page_constraints, diff --git a/spec/views/forms/exit_pages/show.html.erb_spec.rb b/spec/views/forms/exit_pages/show.html.erb_spec.rb new file mode 100644 index 000000000..08236329e --- /dev/null +++ b/spec/views/forms/exit_pages/show.html.erb_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" + +describe "forms/exit_pages/show.html.erb" do + let(:form) { build :form, :with_support, id: 1, name: "exit page form" } + let(:mode) { OpenStruct.new(preview_draft?: false, preview_archived?: false, preview_live?: false) } + let(:condition) { OpenStruct.new({ exit_page_heading: "heading", exit_page_markdown: " * first line\n * second line\n" }) } + let(:support_details) { OpenStruct.new(email: form.support_email) } + + before do + assign(:current_context, OpenStruct.new(form:)) + assign(:mode, mode) + assign(:condition, condition) + assign(:back_link, "/back") + assign(:support_details, support_details) + + render + end + + it "has the correct title" do + expect(view.content_for(:title)).to eq "heading - exit page form" + end + + it "has a back link" do + expect(view.content_for(:back_link)).to have_link("Back", href: "/back") + end + + it "has the correct heading" do + expect(rendered).to have_css("h1", text: condition.exit_page_heading) + end + + it "displays the markdown" do + expect(rendered).to have_css("li", text: "second line") + end + + it "displays the help link" do + expect(rendered).to have_text(I18n.t("support_details.get_help_with_this_form")) + end +end From a3ccf2e98d2aeaaacb7f29a93ef5f21f2e4fc560 Mon Sep 17 00:00:00 2001 From: Tom Iles Date: Tue, 11 Mar 2025 10:28:00 +0000 Subject: [PATCH 2/4] Add exit_page helpers to Step Add two helpers which help us identify when a step has a condition which leads to an exit page. --- app/models/step.rb | 10 +++++++++ spec/models/step_spec.rb | 45 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/app/models/step.rb b/app/models/step.rb index 96a794738..5af4b35b7 100644 --- a/app/models/step.rb +++ b/app/models/step.rb @@ -106,6 +106,16 @@ def conditions_with_goto_errors end end + def has_exit_page_condition? + return false unless routing_conditions&.first.respond_to?(:exit_page_markdown) + + routing_conditions.first.exit_page_markdown.is_a?(String) + end + + def exit_page_condition_matches? + first_condition_matches? && has_exit_page_condition? + end + private def goto_condition_page_slug(condition) diff --git a/spec/models/step_spec.rb b/spec/models/step_spec.rb index 34f5167d4..2eb5b2f92 100644 --- a/spec/models/step_spec.rb +++ b/spec/models/step_spec.rb @@ -420,4 +420,49 @@ end end end + + describe "#has_exit_page_condition?" do + it "returns false when no routing conditions" do + expect(step.has_exit_page_condition?).to be false + end + + it "returns false when first routing condition is not exit page" do + page.routing_conditions = [OpenStruct.new(answer_value: "Yes", goto_page_id: "5")] + expect(step.has_exit_page_condition?).to be false + end + + it "returns false when first routing condition contains markdown exit_page_markdown" do + page.routing_conditions = [OpenStruct.new(exit_page_markdown: 12)] + expect(step.has_exit_page_condition?).to be false + end + + it "returns true when first routing condition contains string markdown exit_page_markdown" do + page.routing_conditions = [OpenStruct.new(exit_page_markdown: "")] + expect(step.has_exit_page_condition?).to be true + end + end + + describe "#exit_page_condition_matches?" do + let(:selection) { "Yes" } + let(:question) { instance_double(Question::Selection, selection:) } + let(:routing_conditions) { [OpenStruct.new(answer_value: "Yes", exit_page_markdown: "string")] } + let(:page) { build(:page, id: 2, position: 1, routing_conditions:) } + + it "returns true when condition matches and condition is an exit page" do + expect(step.exit_page_condition_matches?).to be true + end + + it "when condition matches but not an exit page it returns false" do + routing_conditions.first.exit_page_markdown = nil + expect(step.exit_page_condition_matches?).to be false + end + + context "when condition doesn't match" do + let(:selection) { "No" } + + it "returns false" do + expect(step.exit_page_condition_matches?).to be false + end + end + end end From d98f203eee384ce648b0d136927a325dc95565b8 Mon Sep 17 00:00:00 2001 From: Tom Iles Date: Tue, 11 Mar 2025 10:28:29 +0000 Subject: [PATCH 3/4] Redirect to exit_page if answer matches Exit pages do not route on to other questions in the form when the condition is met. To represent this, Step#next_page_slug_after_routing returns nil when the page matches. This allows the journey code to stop looking for the next page and return. Exit page conditions already have a goto_page_id set to nil, so we could let this value be passed on more implicitly. Handling the case early clearly shows our intention. Exit_pages don't go to another page when they have been selected. --- app/controllers/forms/page_controller.rb | 1 + app/models/step.rb | 4 ++++ spec/requests/forms/page_controller_spec.rb | 20 ++++++++++++++++++++ 3 files changed, 25 insertions(+) diff --git a/app/controllers/forms/page_controller.rb b/app/controllers/forms/page_controller.rb index 7b88ea75a..b8627a598 100644 --- a/app/controllers/forms/page_controller.rb +++ b/app/controllers/forms/page_controller.rb @@ -72,6 +72,7 @@ def back_link(page_slug) def redirect_post_save return redirect_to review_file_page, success: t("banner.success.file_uploaded") if answered_file_question? + return redirect_to exit_page_path(form_id: @step.form_id, form_slug: @step.form_slug, page_slug: @step.page_slug) if @step.exit_page_condition_matches? redirect_to next_page end diff --git a/app/models/step.rb b/app/models/step.rb index 5af4b35b7..615a19ab2 100644 --- a/app/models/step.rb +++ b/app/models/step.rb @@ -79,6 +79,10 @@ def end_page? end def next_page_slug_after_routing + if exit_page_condition_matches? + return nil + end + if first_condition_default? return goto_condition_page_slug(routing_conditions.first) end diff --git a/spec/requests/forms/page_controller_spec.rb b/spec/requests/forms/page_controller_spec.rb index dba6da398..993093d92 100644 --- a/spec/requests/forms/page_controller_spec.rb +++ b/spec/requests/forms/page_controller_spec.rb @@ -651,6 +651,26 @@ end end end + + context "when the page is a an exit question" do + let(:first_step_in_form) do + build :v2_question_page_step, :with_selections_settings, + id: 1, + next_step_id: 2, + routing_conditions: [DataStruct.new(id: 1, routing_page_id: 1, check_page_id: 1, goto_page_id: nil, answer_value: "Option 1", validation_errors: [], exit_page_markdown: "Exit page markdown", exit_page_heading: "exit page heading")], + is_optional: false + end + + it "redirects to the exit page when exit page answer given" do + post save_form_page_path(mode:, form_id: 2, form_slug: form_data.form_slug, page_slug: 1, params: { question: { selection: "Option 1" }, changing_existing_answer: false }) + expect(response).to redirect_to exit_page_path(mode:, form_id: 2, form_slug: form_data.form_slug, page_slug: 1) + end + + it "redirects to the next step in the form when any other answer given" do + post save_form_page_path(mode:, form_id: 2, form_slug: form_data.form_slug, page_slug: 1, params: { question: { selection: "Option 2" }, changing_existing_answer: false }) + expect(response).to redirect_to form_page_path(mode:, form_id: 2, form_slug: form_data.form_slug, page_slug: 2) + end + end end def log_lines From b17b892ab32eb8ab43a4f55f250f95ef306e9b06 Mon Sep 17 00:00:00 2001 From: Tom Iles Date: Wed, 12 Mar 2025 15:52:14 +0000 Subject: [PATCH 4/4] Add feature test for exit_pages Add a feature test for the basic journey of visiting an exit page, pressing back and changing the answer to complete the form. --- .../fill_in_form_with_exit_page_spec.rb | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 spec/features/fill_in_form_with_exit_page_spec.rb diff --git a/spec/features/fill_in_form_with_exit_page_spec.rb b/spec/features/fill_in_form_with_exit_page_spec.rb new file mode 100644 index 000000000..0553e6c7c --- /dev/null +++ b/spec/features/fill_in_form_with_exit_page_spec.rb @@ -0,0 +1,105 @@ +require "rails_helper" + +feature "Fill in and submit a form with an exit page", type: :feature do + let(:routing_conditions) { [DataStruct.new(routing_page_id: 1, check_page_id: 1, answer_value: "Option 1", goto_page_id: nil, exit_page_heading: "This is an exit_page", exit_page_markdown: "This is the contents", validation_errors: [])] } + let(:steps) { [(build :v2_question_page_step, :with_selections_settings, id: 1, routing_conditions:, question_text:)] } + let(:form) { build :v2_form_document, :live?, id: 1, name: "Fill in this form", steps:, start_page: 1 } + let(:question_text) { Faker::Lorem.question } + let(:reference) { Faker::Alphanumeric.alphanumeric(number: 8).upcase } + + let(:req_headers) do + { + "X-API-Token" => Settings.forms_api.auth_key, + "Accept" => "application/json", + } + end + + let(:post_headers) do + { + "X-API-Token" => Settings.forms_api.auth_key, + "Content-Type" => "application/json", + } + end + + before do + ActiveResource::HttpMock.respond_to do |mock| + mock.get "/api/v2/forms/1/live", req_headers, form.to_json, 200 + end + + allow(ReferenceNumberService).to receive(:generate).and_return(reference) + end + + scenario "As a form filler" do + when_i_visit_the_form_start_page + then_i_should_see_the_first_question + + when_i_choose_the_exit_option + and_i_click_on_continue + then_i_should_see_the_exit_page + + when_i_click_back + when_i_dont_choose_the_exit_option + and_i_click_on_continue + then_i_should_see_the_check_your_answers_page + + when_i_opt_out_of_email_confirmation + and_i_submit_my_form + then_my_form_should_be_submitted + and_i_should_receive_a_reference_number + end + + def when_i_visit_the_form_start_page + visit form_path(mode: "form", form_id: 1, form_slug: "fill-in-this-form") + expect_page_to_have_no_axe_errors(page) + end + + def then_i_should_see_the_first_question + expect(page.find("h1")).to have_text question_text + end + + def when_i_choose_the_exit_option + choose "Option 1" + end + + def when_i_dont_choose_the_exit_option + choose "Option 2" + end + + def and_i_click_on_continue + click_button "Continue" + end + + def then_i_should_see_the_check_your_answers_page + expect(page.find("h1")).to have_text "Check your answers before submitting your form" + expect(page).to have_text question_text + expect(page).to have_text "Option 2" + expect_page_to_have_no_axe_errors(page) + end + + def when_i_click_back + click_on "Back" + end + + def then_i_should_see_the_exit_page + expect(page.find("h1")).to have_text "This is an exit_page" + expect(page).to have_text "This is the contents" + expect_page_to_have_no_axe_errors(page) + end + + def when_i_opt_out_of_email_confirmation + choose "No" + end + + def and_i_submit_my_form + click_on "Submit" + end + + def then_my_form_should_be_submitted + expect(page.find("h1")).to have_text "Your form has been submitted" + expect_page_to_have_no_axe_errors(page) + end + + def and_i_should_receive_a_reference_number + expect(page).to have_text reference + end +end