From 29f1d72c38fe0b4d1634ad4d740486af1f47a670 Mon Sep 17 00:00:00 2001 From: Thomas Iles Date: Wed, 12 Aug 2026 16:29:16 +0100 Subject: [PATCH 1/9] Extract exit page validation to concern --- app/input_objects/pages/exit_page_input.rb | 4 +-- app/models/concerns/exit_page_validation.rb | 9 ++++++ .../pages/exit_page_input_spec.rb | 28 ++----------------- .../shared_examples/exit_page_validation.rb | 23 +++++++++++++++ 4 files changed, 35 insertions(+), 29 deletions(-) create mode 100644 app/models/concerns/exit_page_validation.rb create mode 100644 spec/support/shared_examples/exit_page_validation.rb diff --git a/app/input_objects/pages/exit_page_input.rb b/app/input_objects/pages/exit_page_input.rb index c51bc9ed62..b0de027082 100644 --- a/app/input_objects/pages/exit_page_input.rb +++ b/app/input_objects/pages/exit_page_input.rb @@ -1,9 +1,7 @@ class Pages::ExitPageInput < BaseInput attr_accessor :page, :markdown, :heading - validates :heading, :markdown, presence: true - validates :heading, length: { maximum: 250 } - validates :markdown, markdown: { allow_headings: true } + include ExitPageValidation def submit return false if invalid? diff --git a/app/models/concerns/exit_page_validation.rb b/app/models/concerns/exit_page_validation.rb new file mode 100644 index 0000000000..002b843ca5 --- /dev/null +++ b/app/models/concerns/exit_page_validation.rb @@ -0,0 +1,9 @@ +module ExitPageValidation + extend ActiveSupport::Concern + + included do + validates :heading, :markdown, presence: true + validates :heading, length: { maximum: 250 } + validates :markdown, markdown: { allow_headings: true } + end +end diff --git a/spec/input_objects/pages/exit_page_input_spec.rb b/spec/input_objects/pages/exit_page_input_spec.rb index 05e8da2dbc..dc23933600 100644 --- a/spec/input_objects/pages/exit_page_input_spec.rb +++ b/spec/input_objects/pages/exit_page_input_spec.rb @@ -6,32 +6,8 @@ let(:heading) { "the heading" } let(:markdown) { "some markdown" } - describe "validations" do - it "is invalid if heading is nil" do - error_message = I18n.t("activemodel.errors.models.pages/exit_page_input.attributes.heading.blank") - exit_page_input.heading = nil - expect(exit_page_input).to be_invalid - expect(exit_page_input.errors.full_messages_for(:heading)).to include("Heading #{error_message}") - end - - it "is invalid if markdown is nil" do - error_message = I18n.t("activemodel.errors.models.pages/exit_page_input.attributes.markdown.blank") - exit_page_input.markdown = nil - expect(exit_page_input).to be_invalid - expect(exit_page_input.errors.full_messages_for(:markdown)).to include("Markdown #{error_message}") - end - - it "is invalid if heading is too long" do - error_message = I18n.t("activemodel.errors.models.pages/exit_page_input.attributes.heading.too_long", count: 250) - exit_page_input.heading = "a" * 251 - expect(exit_page_input).to be_invalid - expect(exit_page_input.errors.full_messages_for(:heading)).to include("Heading #{error_message}") - end - - it_behaves_like "a markdown field with headings allowed", :mark_complete do - let(:model) { exit_page_input } - let(:attribute) { :markdown } - end + it_behaves_like "validates exit pages" do + let(:model) { exit_page_input } end describe "#submit" do diff --git a/spec/support/shared_examples/exit_page_validation.rb b/spec/support/shared_examples/exit_page_validation.rb new file mode 100644 index 0000000000..db965c3430 --- /dev/null +++ b/spec/support/shared_examples/exit_page_validation.rb @@ -0,0 +1,23 @@ +RSpec.shared_examples "validates exit pages" do + it "is invalid if heading is nil" do + model.heading = nil + expect(model).to be_invalid + expect(model.errors.where(:heading, :blank)).to be_present + end + + it "is invalid if markdown is nil" do + exit_page_input.markdown = nil + expect(exit_page_input).to be_invalid + expect(model.errors.where(:markdown, :blank)).to be_present + end + + it "is invalid if heading is too long" do + exit_page_input.heading = "a" * 251 + expect(exit_page_input).to be_invalid + expect(model.errors.where(:heading, :too_long)).to be_present + end + + it_behaves_like "a markdown field with headings allowed" do + let(:attribute) { :markdown } + end +end From ffe5aa2bcf160e4499196842d0b4eab1422f87db Mon Sep 17 00:00:00 2001 From: Thomas Iles Date: Thu, 13 Aug 2026 14:30:26 +0100 Subject: [PATCH 2/9] Add optional block to save_question_changes! When making changes to some models, we want to reset the question_section_completed flag and update the draft. We do this in some places by calling save_question_changes! after making the changes. To make this more consistent, we can add an optional block to save_question_changes! which will be called in the same transaction as the save. This allows us to pass our changes in a block and be sure the draft is updated. diff --git a/app/models/form.rb b/app/models/form.rb index 22cf9c7ce..7dc3f9ceb 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -51,11 +51,18 @@ class Form < ApplicationRecord attr_accessor :task_status_service --- app/models/form.rb | 17 ++++++++++++----- spec/models/form_spec.rb | 19 +++++++++++++++++++ 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/app/models/form.rb b/app/models/form.rb index 22cf9c7ce6..7dc3f9cebe 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -51,11 +51,18 @@ class Form < ApplicationRecord attr_accessor :task_status_service - def save_question_changes! - self.question_section_completed = false - # Make sure the updated_at is updated as we use this to determine if the form has changed in forms-runner. - touch unless changed? - save_draft! + # Takes an optional blocl which will be called in the same transaction + # as the save. + def save_question_changes!(&block) + ActiveRecord::Base.transaction do + self.question_section_completed = false + + block.call if block_given? + + # Make sure the updated_at is updated as we use this to determine if the form has changed in forms-runner. + touch unless changed? + save_draft! + end end def save_draft! diff --git a/spec/models/form_spec.rb b/spec/models/form_spec.rb index 8140b71aeb..f66f33ab1f 100644 --- a/spec/models/form_spec.rb +++ b/spec/models/form_spec.rb @@ -666,6 +666,25 @@ }.to(change { form.reload.draft_form_document.content["updated_at"] }) end end + + context "when a block is passed" do + it "calls the block" do + expect { + form.save_question_changes! do + form.name = "New name" + end + }.to change { form.reload.name }.to("New name") + end + + it "does not update draft if the block fails" do + expect { + form.save_question_changes! do + form.name = "New name" + raise "Something went wrong" + end + }.to raise_error("Something went wrong").and(not_change { form.reload.draft_form_document.content["name"] }).and(not_change { form.reload.updated_at }) + end + end end describe "#save_draft!" do From 7f9c21eab41911011bb8f75ea5c5bb8f00749e01 Mon Sep 17 00:00:00 2001 From: Thomas Iles Date: Thu, 13 Aug 2026 14:34:29 +0100 Subject: [PATCH 3/9] Update draft when creating new exit pages Creating a new exit page should create a new draft and set the question_section_completed flag to false. --- app/input_objects/pages/exit_page_input.rb | 4 +++- spec/input_objects/pages/exit_page_input_spec.rb | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/input_objects/pages/exit_page_input.rb b/app/input_objects/pages/exit_page_input.rb index b0de027082..b84494bc63 100644 --- a/app/input_objects/pages/exit_page_input.rb +++ b/app/input_objects/pages/exit_page_input.rb @@ -6,6 +6,8 @@ class Pages::ExitPageInput < BaseInput def submit return false if invalid? - ExitPage.create!(question_page: page, heading:, markdown:) + page.form.save_question_changes! do + ExitPage.create!(question_page: page, heading:, markdown:) + end end end diff --git a/spec/input_objects/pages/exit_page_input_spec.rb b/spec/input_objects/pages/exit_page_input_spec.rb index dc23933600..7569f6507d 100644 --- a/spec/input_objects/pages/exit_page_input_spec.rb +++ b/spec/input_objects/pages/exit_page_input_spec.rb @@ -16,6 +16,7 @@ let(:heading) { "the heading" } let(:markdown) { "some markdown" } let(:page) { create :page } + let(:form) { page.form } it "returns a truthy value" do expect(exit_page_input.submit).to be_truthy @@ -31,6 +32,12 @@ expect(page.exit_pages.first.markdown).to eq(markdown) end + it "sets question_section_completed to false and updates the draft" do + form.question_section_completed = true + expect { exit_page_input.submit }.to change(form, :question_section_completed).from(true).to(false) + .and(change { form.draft_form_document.reload.updated_at }) + end + context "when the exit page is invalid" do let(:heading) { nil } From f9c7da37dd6b3dac1a0afe92ea1b838bd5c0394a Mon Sep 17 00:00:00 2001 From: Thomas Iles Date: Mon, 10 Aug 2026 17:04:09 +0100 Subject: [PATCH 4/9] Add update exit page input object --- .../pages/update_exit_page_input.rb | 21 +++++++++ .../input_objects/update_exit_pages.yml | 23 +++++++++ .../pages/update_exit_page_input_spec.rb | 47 +++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 app/input_objects/pages/update_exit_page_input.rb create mode 100644 config/locales/input_objects/update_exit_pages.yml create mode 100644 spec/input_objects/pages/update_exit_page_input_spec.rb diff --git a/app/input_objects/pages/update_exit_page_input.rb b/app/input_objects/pages/update_exit_page_input.rb new file mode 100644 index 0000000000..732b86409f --- /dev/null +++ b/app/input_objects/pages/update_exit_page_input.rb @@ -0,0 +1,21 @@ +class Pages::UpdateExitPageInput < BaseInput + attr_accessor :page, :markdown, :heading, :exit_page + + include ExitPageValidation + + def assign_exit_page_values + self.page = exit_page.question_page + self.heading = exit_page.heading + self.markdown = exit_page.markdown + + self + end + + def submit + return false if invalid? + + page.form.save_question_changes! do + exit_page.update!(heading: heading, markdown: markdown) + end + end +end diff --git a/config/locales/input_objects/update_exit_pages.yml b/config/locales/input_objects/update_exit_pages.yml new file mode 100644 index 0000000000..3fecc8b811 --- /dev/null +++ b/config/locales/input_objects/update_exit_pages.yml @@ -0,0 +1,23 @@ +--- +en: + activemodel: + errors: + models: + pages/update_exit_page_input: + attributes: + heading: + blank: Enter a page heading + too_long: Page heading must be %{count} characters or less + markdown: + blank: Enter page content + too_long: Page content must be %{count} characters or less + unsupported_markdown_syntax: Page content can only contain formatting for links, subheadings (##), bulleted lists (*), or numbered lists (1.) + helpers: + hint: + pages_update_exit_page_input: + heading: Use a heading that summarises why someone cannot continue with the form. For example, ‘You’re not eligible for this service’. + markdown: Explain why they cannot continue to use the form and, if possible, tell them what they should do instead. + label: + pages_update_exit_page_input: + heading: Page heading + markdown: Page content diff --git a/spec/input_objects/pages/update_exit_page_input_spec.rb b/spec/input_objects/pages/update_exit_page_input_spec.rb new file mode 100644 index 0000000000..b9b6be59af --- /dev/null +++ b/spec/input_objects/pages/update_exit_page_input_spec.rb @@ -0,0 +1,47 @@ +require "rails_helper" + +RSpec.describe Pages::UpdateExitPageInput, type: :model do + subject(:exit_page_input) { described_class.new(heading:, markdown:) } + + let(:heading) { "the heading" } + let(:markdown) { "some markdown" } + + it_behaves_like "validates exit pages" do + let(:model) { exit_page_input } + end + + describe "#submit" do + subject(:exit_page_input) { described_class.new(heading:, markdown:, page:, exit_page:) } + + let(:heading) { "the heading" } + let(:markdown) { "some markdown" } + let(:form) { page.form } + let(:page) { create :page } + let(:exit_page) { create :exit_page, question_page: page } + + it "returns a truthy value" do + expect(exit_page_input.submit).to be_truthy + end + + it "updates the exit page" do + exit_page_input.submit + expect(exit_page.question_page).to eq(page) + expect(exit_page.heading).to eq(heading) + expect(exit_page.markdown).to eq(markdown) + end + + it "sets question_section_completed to false and updates the draft" do + form.question_section_completed = true + expect { exit_page_input.submit }.to change(form, :question_section_completed).from(true).to(false) + .and(change { form.draft_form_document.reload.updated_at }) + end + + context "when the exit page is invalid" do + let(:heading) { nil } + + it "returns false" do + expect(exit_page_input.submit).to be false + end + end + end +end From c74d1ef00905c9edea7d505f67d4c7667896969e Mon Sep 17 00:00:00 2001 From: Thomas Iles Date: Wed, 12 Aug 2026 16:28:13 +0100 Subject: [PATCH 5/9] Add options_to_this_exit_page --- app/models/exit_page.rb | 6 ++++++ spec/models/exit_page_spec.rb | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/app/models/exit_page.rb b/app/models/exit_page.rb index 94ad3eb143..e28c2c0b0f 100644 --- a/app/models/exit_page.rb +++ b/app/models/exit_page.rb @@ -25,4 +25,10 @@ def self.positions_for_page(question_page) [exit_page_id, index + 1] end end + + def options_to_this_exit_page + return [] if conditions.nil? + + conditions.pluck(:answer_value) + end end diff --git a/spec/models/exit_page_spec.rb b/spec/models/exit_page_spec.rb index 4da57eae6d..959e7eb019 100644 --- a/spec/models/exit_page_spec.rb +++ b/spec/models/exit_page_spec.rb @@ -101,4 +101,23 @@ ) end end + + describe "#options_to_this_exit_page" do + it "when the exit_page has linked conditions, returns an array of the answer values of conditions that go to this exit page" do + question_page = create(:page) + exit_page = create(:exit_page, question_page:) + create(:condition, routing_page: question_page, exit_page_id: exit_page.id, answer_value: "option 1") + create(:condition, exit_page:, answer_value: "option 2") + create(:condition, exit_page:, answer_value: "option 3") + + expect(exit_page.options_to_this_exit_page).to eq(["option 1", "option 2", "option 3"]) + end + + it "when the exit_page has no linked conditions, returns an empty array" do + question_page = create(:page) + exit_page = create(:exit_page, question_page:) + + expect(exit_page.options_to_this_exit_page).to eq([]) + end + end end From e6db7f0afb616424fc96eb2497f699958f9bc668 Mon Sep 17 00:00:00 2001 From: Thomas Iles Date: Mon, 10 Aug 2026 17:04:35 +0100 Subject: [PATCH 6/9] Add exitPage#edit view --- app/views/pages/exit_pages/edit.html.erb | 57 ++++++++++++ config/locales/en.yml | 5 ++ .../pages/exit_pages/edit.html.erb_spec.rb | 87 +++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 app/views/pages/exit_pages/edit.html.erb create mode 100644 spec/views/pages/exit_pages/edit.html.erb_spec.rb diff --git a/app/views/pages/exit_pages/edit.html.erb b/app/views/pages/exit_pages/edit.html.erb new file mode 100644 index 0000000000..db04104da6 --- /dev/null +++ b/app/views/pages/exit_pages/edit.html.erb @@ -0,0 +1,57 @@ +<% set_page_title(title_with_error_prefix(t("page_titles.exit_page_edit"), exit_page_input.errors&.any?)) %> +<% content_for :back_link, govuk_back_link_to(routes_path(@current_form.id), t(".back_link")) %> + +
+
+ + <%= form_with model: exit_page_input, url: exit_page_path(@current_form.id, exit_page_input.exit_page.question_page.id, exit_page_input.exit_page.id), method: 'PATCH' do |f| %> + <%= f.govuk_error_summary %> +

+ <%= t("page_titles.exit_page_new_caption", question_number: exit_page_input.page.position) %> + - + <%= t("page_titles.exit_page_edit") %> +

+ + <% options = exit_page_input.exit_page.options_to_this_exit_page %> + + <%= govuk_summary_list(actions: false) do |summary_list| + summary_list.with_row do |row| + row.with_key { t("page_route_card.question_name_short", question_number: exit_page_input.page.position) } + row.with_value { "#{exit_page_input.page.question_text}" } + end; + + summary_list.with_row do |row| + row.with_key { t(".options_to_this_exit_page") } + row.with_value do + if options.empty? + t(".no_options_to_this_exit_page") + elsif options.one? + options.first + else + content_tag(:ul, class: "govuk-list govuk-list--bullet") do + options.collect { |option| concat(content_tag(:li, option)) } + end + end + end + end; + end %> + + <%= f.govuk_text_field( :heading, label: { size: 'm' } ) %> + + <%= render MarkdownEditorComponent::View.new(:markdown, + form_builder: f, + render_preview_path: render_preview_exit_pages_path(form_id: @current_form.id, page_id: exit_page_input.exit_page.question_page.id, check_preview_validation:), + preview_html: preview_html, + form_model: exit_page_input, + label: t("helpers.label.pages_exit_page_input.markdown"), + hint: t("helpers.hint.pages_exit_page_input.markdown"), + allow_headings: true) %> + +
+ <%= f.govuk_submit t("save_and_continue") %> + <%= govuk_link_to t('cancel'), routes_path(@current_form.id) %> +
+ <% end %> +
+
+ diff --git a/config/locales/en.yml b/config/locales/en.yml index b1caffb3a5..db9320f171 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1745,6 +1745,7 @@ en: edit_exit_page: Edit exit page email_code_sent: Confirmation code sent error_prefix: 'Error: ' + exit_page_edit: Edit exit page exit_page_new: Add exit page exit_page_new_caption: Question %{question_number}’s exit pages forbidden: You cannot view this page @@ -1897,6 +1898,10 @@ en: destroy: success: The question, ‘%{question_text}’, has been deleted exit_pages: + edit: + back_link: Back to edit question routes + no_options_to_this_exit_page: No route to this exit page + options_to_this_exit_page: Options that go to this exit page new: back_link: Back to edit question routes heading: Edit question diff --git a/spec/views/pages/exit_pages/edit.html.erb_spec.rb b/spec/views/pages/exit_pages/edit.html.erb_spec.rb new file mode 100644 index 0000000000..a9853ba8e7 --- /dev/null +++ b/spec/views/pages/exit_pages/edit.html.erb_spec.rb @@ -0,0 +1,87 @@ +require "rails_helper" + +describe "exit_pages/edit.html.erb" do + let(:form) { build_stubbed :form, :with_pages, pages: [build_stubbed(:page, id: 1)] } + let(:page) { form.pages.first } + let(:exit_page) { build_stubbed(:exit_page, question_page: page) } + let(:exit_page_input) { Pages::UpdateExitPageInput.new(page:, exit_page:) } + + def render_page + assign(:current_form, form) + assign(:page, page) + render template: "pages/exit_pages/edit", locals: { exit_page_input:, preview_html: "", check_preview_validation: false } + end + + it "has the correct title" do + render_page + expect(view.content_for(:title)).to have_content("Edit exit page") + end + + it "has the correct back link" do + render_page + expect(view.content_for(:back_link)).to have_link("Back to edit question routes", href: routes_path(form.id)) + end + + it "has the correct heading and caption" do + render_page + expect(rendered).to have_selector "h1", text: "Question 1’s exit pages" + expect(rendered).to have_selector "h1", text: "Edit exit page" + end + + it "shows the page title as a summary list" do + render_page + expect(rendered).to have_css(".govuk-summary-list__key", text: "Question #{page.position}") + expect(rendered).to have_css(".govuk-summary-list__value", text: page.question_text) + end + + context "when the exit page has no options" do + before do + allow(exit_page).to receive(:options_to_this_exit_page).and_return([]) + end + + it "shows the page title as a summary list" do + render_page + + expect(rendered).to have_css(".govuk-summary-list__key", text: "Options that go to this exit page") + expect(rendered).to have_css(".govuk-summary-list__value", text: "No route to this exit page") + end + end + + context "when the exit page has has a single option" do + let(:options) { %w[option1] } + + before do + allow(exit_page).to receive(:options_to_this_exit_page).and_return(options) + end + + it "shows the page title as a summary list" do + render_page + + expect(rendered).to have_css(".govuk-summary-list__key", text: "Options that go to this exit page") + expect(rendered).to have_css(".govuk-summary-list__value:text()", text: "option1", exact: true) + end + end + + context "when the exit page has multiple options" do + let(:options) { %w[option1 option2 option3] } + + before do + allow(exit_page).to receive(:options_to_this_exit_page).and_return(options) + end + + it "shows the page title as a summary list" do + render_page + + expect(rendered).to have_css(".govuk-summary-list__key", text: "Options that go to this exit page") + expect(rendered).to have_css("li", text: options.first) + expect(rendered).to have_css("li", text: options.second) + expect(rendered).to have_css("li", text: options.third) + end + end + + it "shows error messages" do + exit_page_input.errors.add(:heading, "Error: Heading can't be blank") + render_page + expect(rendered).to have_text("Error: Heading can't be blank") + end +end From 8695e5aefb71d63800a016812bcb0faa595ff6d9 Mon Sep 17 00:00:00 2001 From: Thomas Iles Date: Mon, 10 Aug 2026 17:05:08 +0100 Subject: [PATCH 7/9] Add update exit page route and controller --- .../pages/exit_pages_controller.rb | 24 +++++++ config/routes.rb | 2 +- .../pages/exit_pages_controller_spec.rb | 68 +++++++++++++++++++ 3 files changed, 93 insertions(+), 1 deletion(-) diff --git a/app/controllers/pages/exit_pages_controller.rb b/app/controllers/pages/exit_pages_controller.rb index 43a53f0186..d08c459ec1 100644 --- a/app/controllers/pages/exit_pages_controller.rb +++ b/app/controllers/pages/exit_pages_controller.rb @@ -18,6 +18,22 @@ def create end end + def edit + exit_page_input = Pages::UpdateExitPageInput.new(exit_page:).assign_exit_page_values + + render locals: { exit_page_input:, preview_html: preview_html(exit_page_input), check_preview_validation: false } + end + + def update + exit_page_input = Pages::UpdateExitPageInput.new(update_exit_page_input_params.merge(exit_page:)) + + if exit_page_input.submit + redirect_to routes_path(form_id: current_form.id), success: t("banner.success.exit_page_saved") + else + render :edit, locals: { exit_page_input:, preview_html: preview_html(exit_page_input), check_preview_validation: true }, status: :unprocessable_content + end + end + def render_preview exit_page_input = Pages::ExitPageInput.new(markdown: params[:markdown]) exit_page_input.validate if params[:check_preview_validation] == "true" @@ -27,10 +43,18 @@ def render_preview private + def exit_page + @exit_page ||= page.exit_pages.find(params[:id]) + end + def exit_page_input_params params.require(:pages_exit_page_input).permit(:heading, :markdown).merge(page:) end + def update_exit_page_input_params + params.require(:pages_update_exit_page_input).permit(:heading, :markdown).merge(page:) + end + def check_user_has_permission authorize current_form, :can_edit_form? end diff --git a/config/routes.rb b/config/routes.rb index 537e7dda89..df34811e2f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -160,7 +160,7 @@ end end - resources :exit_pages, only: %i[new create], path: "exit-pages", module: :pages do + resources :exit_pages, only: %i[new create edit update], path: "exit-pages", module: :pages do collection do post "/preview" => "exit_pages#render_preview", as: :render_preview end diff --git a/spec/requests/pages/exit_pages_controller_spec.rb b/spec/requests/pages/exit_pages_controller_spec.rb index 4da13932a4..42ef4c70d9 100644 --- a/spec/requests/pages/exit_pages_controller_spec.rb +++ b/spec/requests/pages/exit_pages_controller_spec.rb @@ -75,6 +75,74 @@ end end + describe "#edit" do + let(:exit_page) { create(:exit_page, question_page: page) } + + before do + get edit_exit_page_path(form.id, page.id, exit_page.id) + end + + it "renders the template" do + expect(response).to have_rendered("exit_pages/edit") + expect(response.body).to include("Edit exit page") + end + + context "when the multiple branches feature is not enabled", feature_multiple_branches: false do + it "is forbidden" do + expect(response).to have_http_status(:not_found) + end + end + + context "when the user is not in the form's group" do + let(:group) { create(:group, organisation: test_org, memberships: []) } + + it "returns a forbidden status code" do + expect(response).to have_http_status :forbidden + end + end + end + + describe "#update" do + let(:exit_page) { create(:exit_page, question_page: page) } + + before do + patch exit_page_path(form.id, page.id, exit_page.id), params: { pages_update_exit_page_input: { heading: "Exit Page Heading", markdown: "Exit Page Markdown" } } + end + + it "updates the exit page" do + expect(response).to redirect_to(routes_path(form.id)) + expect(flash[:success]).to eq(I18n.t("banner.success.exit_page_saved")) + + expect(exit_page.reload.heading).to eq("Exit Page Heading") + expect(exit_page.reload.markdown).to eq("Exit Page Markdown") + end + + context "when the exit page is invalid" do + before do + patch exit_page_path(form.id, page.id, exit_page.id), params: { pages_update_exit_page_input: { heading: nil, markdown: nil } } + end + + it "renders the exit page template" do + expect(response).to have_http_status(:unprocessable_content) + expect(response).to render_template("exit_pages/edit") + end + end + + context "when the multiple branches feature is not enabled", feature_multiple_branches: false do + it "is forbidden" do + expect(response).to have_http_status(:not_found) + end + end + + context "when the user is not in the form's group" do + let(:group) { create(:group, organisation: test_org, memberships: []) } + + it "returns a forbidden status code" do + expect(response).to have_http_status :forbidden + end + end + end + describe "#render_preview" do let(:markdown) { "[Markdown](https://example.com)" } From b316d109ea69045eb29fd0ccdc690a7690f8a4fd Mon Sep 17 00:00:00 2001 From: Thomas Iles Date: Tue, 11 Aug 2026 16:37:33 +0100 Subject: [PATCH 8/9] Add delete exit page --- .../pages/exit_pages_controller.rb | 26 ++++++- .../pages/delete_exit_page_input.rb | 2 + app/views/pages/exit_pages/delete.html.erb | 13 ++++ app/views/pages/exit_pages/edit.html.erb | 1 + config/locales/en.yml | 6 ++ config/routes.rb | 6 +- .../pages/exit_pages_controller_spec.rb | 72 +++++++++++++++++++ .../pages/exit_pages/delete.html.erb_spec.rb | 43 +++++++++++ 8 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 app/input_objects/pages/delete_exit_page_input.rb create mode 100644 app/views/pages/exit_pages/delete.html.erb create mode 100644 spec/views/pages/exit_pages/delete.html.erb_spec.rb diff --git a/app/controllers/pages/exit_pages_controller.rb b/app/controllers/pages/exit_pages_controller.rb index d08c459ec1..caa07b11e4 100644 --- a/app/controllers/pages/exit_pages_controller.rb +++ b/app/controllers/pages/exit_pages_controller.rb @@ -34,6 +34,30 @@ def update end end + def delete + delete_confirmation_input = Pages::DeleteExitPageInput.new + + render locals: { delete_confirmation_input:, page:, exit_page: } + end + + def destroy + delete_confirmation_input = Pages::DeleteExitPageInput.new(params.require(:pages_delete_exit_page_input).permit(:confirm)) + + unless delete_confirmation_input.valid? + return render :delete, locals: { delete_confirmation_input:, page:, exit_page: }, status: :unprocessable_content + end + + unless delete_confirmation_input.confirmed? + return redirect_to edit_exit_page_path(@current_form.id, page.id, exit_page.id) + end + + current_form.save_question_changes! do + exit_page.destroy! + end + + redirect_to routes_path(@current_form.id), success: t("banner.success.exit_page_deleted") + end + def render_preview exit_page_input = Pages::ExitPageInput.new(markdown: params[:markdown]) exit_page_input.validate if params[:check_preview_validation] == "true" @@ -44,7 +68,7 @@ def render_preview private def exit_page - @exit_page ||= page.exit_pages.find(params[:id]) + @exit_page ||= page.exit_pages.find(params.require(:id)) end def exit_page_input_params diff --git a/app/input_objects/pages/delete_exit_page_input.rb b/app/input_objects/pages/delete_exit_page_input.rb new file mode 100644 index 0000000000..ba4f0bb96b --- /dev/null +++ b/app/input_objects/pages/delete_exit_page_input.rb @@ -0,0 +1,2 @@ +class Pages::DeleteExitPageInput < DeleteConfirmationInput +end diff --git a/app/views/pages/exit_pages/delete.html.erb b/app/views/pages/exit_pages/delete.html.erb new file mode 100644 index 0000000000..134e472bb7 --- /dev/null +++ b/app/views/pages/exit_pages/delete.html.erb @@ -0,0 +1,13 @@ +<% set_page_title(title_with_error_prefix(t("page_titles.delete_exit_page"), delete_confirmation_input.errors&.any?)) %> +<% content_for :back_link, govuk_back_link_to(edit_exit_page_path(form_id: @current_form.id, page_id: page.id, id: exit_page.id), t(".back_link")) %> + +
+
+ <%= render( + delete_confirmation_input, + url: exit_page_path(@current_form.id, page.id, exit_page.id), + caption_text: t(".caption", exit_page_number: exit_page.position, heading: exit_page.heading), + legend_text: t(".title"), + ) %> +
+
diff --git a/app/views/pages/exit_pages/edit.html.erb b/app/views/pages/exit_pages/edit.html.erb index db04104da6..294a27bf05 100644 --- a/app/views/pages/exit_pages/edit.html.erb +++ b/app/views/pages/exit_pages/edit.html.erb @@ -49,6 +49,7 @@
<%= f.govuk_submit t("save_and_continue") %> + <%= govuk_button_link_to t('.delete_exit_page'), delete_exit_page_path(@current_form.id, exit_page_input.exit_page.question_page.id, exit_page_input.exit_page.id), warning: true %> <%= govuk_link_to t('cancel'), routes_path(@current_form.id) %>
<% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index db9320f171..61f853c676 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1741,6 +1741,7 @@ en: copy_of_answers: Give people the option to get a copy of their answers by email date_settings: Are you asking for someone’s date of birth? declaration: Add a declaration for people to agree to + delete_exit_page: Are you sure you want to delete this exit page? delete_secondary_skip: Are you sure you want to delete the route for any other answer? edit_exit_page: Edit exit page email_code_sent: Confirmation code sent @@ -1898,8 +1899,13 @@ en: destroy: success: The question, ‘%{question_text}’, has been deleted exit_pages: + delete: + back_link: Back to edit exit page + caption: 'Exit page %{exit_page_number}: %{heading}' + title: Are you sure you want to delete this exit page? edit: back_link: Back to edit question routes + delete_exit_page: Delete exit page no_options_to_this_exit_page: No route to this exit page options_to_this_exit_page: Options that go to this exit page new: diff --git a/config/routes.rb b/config/routes.rb index df34811e2f..6063a23a94 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -160,7 +160,11 @@ end end - resources :exit_pages, only: %i[new create edit update], path: "exit-pages", module: :pages do + resources :exit_pages, except: %i[index show], path: "exit-pages", module: :pages do + member do + get "/delete" => "exit_pages#delete", as: :delete + end + collection do post "/preview" => "exit_pages#render_preview", as: :render_preview end diff --git a/spec/requests/pages/exit_pages_controller_spec.rb b/spec/requests/pages/exit_pages_controller_spec.rb index 42ef4c70d9..cdfc575860 100644 --- a/spec/requests/pages/exit_pages_controller_spec.rb +++ b/spec/requests/pages/exit_pages_controller_spec.rb @@ -143,6 +143,78 @@ end end + describe "#delete" do + let(:exit_page) { create(:exit_page, question_page: page) } + + before do + get delete_exit_page_path(form_id: form.id, page_id: page.id, id: exit_page.id) + end + + it "renders the template" do + expect(response).to have_rendered("exit_pages/delete") + expect(response.body).to include("Are you sure you want to delete this exit page?") + end + + context "when the multiple branches feature is not enabled", feature_multiple_branches: false do + it "is forbidden" do + expect(response).to have_http_status(:not_found) + end + end + + context "when the user is not in the form's group" do + let(:group) { create(:group, organisation: test_org, memberships: []) } + + it "returns a forbidden status code" do + expect(response).to have_http_status :forbidden + end + end + end + + describe "#destroy" do + subject!(:exit_page) { create(:exit_page, question_page: page) } + + let(:form) { create(:form, :with_group, :with_pages, pages_count: 1, group:, question_section_completed: true) } + let(:exit_page_url) { exit_page_path(form_id: form.id, page_id: page.id, id: exit_page.id) } + let(:params) { { pages_delete_exit_page_input: { confirm: "yes" } } } + + it "deletes the exit page" do + expect { delete exit_page_url, params: }.to change(page.reload.exit_pages, :count).by(-1) + end + + it "sets question_section_completed to false and updates the draft" do + expect { delete exit_page_url, params: }.to change { form.reload.question_section_completed? }.from(true).to(false) + .and(change { form.draft_form_document.reload.updated_at }) + end + + it "redirects to the routes page" do + delete(exit_page_url, params:) + expect(response).to redirect_to(routes_path(form_id: form.id)) + end + + it "displays a success flash message" do + delete(exit_page_url, params:) + expect(flash[:success]).to eq(I18n.t("banner.success.exit_page_deleted")) + end + + context "when not confirmed" do + let(:params) { { pages_delete_exit_page_input: { confirm: "no" } } } + + it "redirects to the edit exit page page" do + delete(exit_page_url, params:) + expect(response).to redirect_to(edit_exit_page_path(form_id: form.id, page_id: page.id, id: exit_page.id)) + end + end + + context "when the params are invalid" do + let(:params) { { pages_delete_exit_page_input: { confirm: nil } } } + + it "returns an unprocessable content response" do + delete(exit_page_url, params:) + expect(response).to have_http_status :unprocessable_content + end + end + end + describe "#render_preview" do let(:markdown) { "[Markdown](https://example.com)" } diff --git a/spec/views/pages/exit_pages/delete.html.erb_spec.rb b/spec/views/pages/exit_pages/delete.html.erb_spec.rb new file mode 100644 index 0000000000..4ea07acee5 --- /dev/null +++ b/spec/views/pages/exit_pages/delete.html.erb_spec.rb @@ -0,0 +1,43 @@ +require "rails_helper" + +RSpec.describe "pages/exit_pages/delete" do + let(:delete_confirmation_input) { Forms::DeleteConfirmationInput.new } + let(:current_form) { create :form } + let(:page) { create :page, form: current_form } + let(:exit_page) { create :exit_page, question_page: page, heading: "the heading" } + + before do + assign(:current_form, current_form) + render locals: { page:, exit_page:, delete_confirmation_input: } + end + + it "has a page title" do + expect(view.content_for(:title)).to include "Are you sure you want to delete this exit page?" + end + + it "has a heading" do + expect(rendered).to have_css "h1", text: "Are you sure you want to delete this exit page?" + end + + it "has a heading caption with the question text" do + expect(rendered).to have_css ".govuk-caption-l", text: "Exit page 1: the heading" + end + + it "has a back link to the edit exit page" do + expect(view.content_for(:back_link)).to have_link("Back to edit exit page", href: edit_exit_page_path(form_id: current_form.id, page_id: page.id, id: exit_page.id)) + end + + it "has a delete confirmation input to confirm deletion of the page" do + expect(rendered).to render_template "input_objects/_delete_confirmation_input" + end + + describe "delete confirmation input" do + it "posts to the destroy action" do + expect(rendered).to have_element "form", action: "/forms/#{current_form.id}/pages/#{page.id}/exit-pages/#{exit_page.id}", method: "post" + end + + it "does not have a hint" do + expect(rendered).not_to have_css ".govuk-hint" + end + end +end From e605106e528cde06e4781bc07a5b799749cdb0f5 Mon Sep 17 00:00:00 2001 From: Thomas Iles Date: Thu, 13 Aug 2026 15:39:07 +0100 Subject: [PATCH 9/9] Add check that exit pages can be added to page ExitPages should only be added to pages that have selection options. This commit adds a before_action to check that the page can have exit pages before allowing the user to add, edit or delete them. If the shouldn't have exit pages, the user is redirected to a 404 page. The actions are ordered so that the permission check is first, followed by the others. --- app/controllers/pages/exit_pages_controller.rb | 9 ++++++++- spec/requests/pages/exit_pages_controller_spec.rb | 4 ++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/app/controllers/pages/exit_pages_controller.rb b/app/controllers/pages/exit_pages_controller.rb index caa07b11e4..e8a93aeb2b 100644 --- a/app/controllers/pages/exit_pages_controller.rb +++ b/app/controllers/pages/exit_pages_controller.rb @@ -1,6 +1,7 @@ class Pages::ExitPagesController < PagesController - before_action :check_multiple_branches_enabled before_action :check_user_has_permission + before_action :check_multiple_branches_enabled + before_action :check_page_can_have_exit_pages def new exit_page_input = Pages::ExitPageInput.new(page: page) @@ -89,6 +90,12 @@ def check_multiple_branches_enabled render "errors/not_found", status: :not_found, formats: :html end + def check_page_can_have_exit_pages + return if Forms::RoutesInput.route_with_selection_options?(page) + + render "errors/not_found", status: :not_found, formats: :html + end + def preview_html(exit_page_input_object) return t("exit_page.no_content_added_html") if exit_page_input_object.markdown.blank? diff --git a/spec/requests/pages/exit_pages_controller_spec.rb b/spec/requests/pages/exit_pages_controller_spec.rb index cdfc575860..ad611a422d 100644 --- a/spec/requests/pages/exit_pages_controller_spec.rb +++ b/spec/requests/pages/exit_pages_controller_spec.rb @@ -1,7 +1,7 @@ require "rails_helper" RSpec.describe Pages::ExitPagesController, :feature_multiple_branches, type: :request do - let(:form) { create(:form, :with_group, :with_pages, pages_count: 1, group:) } + let(:form) { create(:form, :with_group, :with_pages, pages: [create(:page, :with_selection_settings)], group:) } let(:page) { form.pages.first } let(:group) { create(:group, organisation: test_org, memberships: [create(:membership, user: standard_user)]) } @@ -173,7 +173,7 @@ describe "#destroy" do subject!(:exit_page) { create(:exit_page, question_page: page) } - let(:form) { create(:form, :with_group, :with_pages, pages_count: 1, group:, question_section_completed: true) } + let(:form) { super().tap { |f| f.question_section_completed = true } } let(:exit_page_url) { exit_page_path(form_id: form.id, page_id: page.id, id: exit_page.id) } let(:params) { { pages_delete_exit_page_input: { confirm: "yes" } } }