Repository: DFE-Digital/teaching-vacancies
Pull request #7616: Various bug fixes on school page
Author: login: jriga
Labels: name: deploy
Created: 2025-03-13T14:06:53Z | Merged: 2025-03-19T15:57:07Z

## Trello card URL
[Link to Card](https://trello.com/c/paDj2CP3)

## Changes in this PR:

- Update `Job Availabity options` in School Page Filter
- Add copy `(in England)` to location field for school search
- Button styling to job alert
- Add wider search suggestions for school page
- Fix wider search suggestions to take account the search original scope



Diff:
diff --git a/app/form_models/school_search_form.rb b/app/form_models/school_search_form.rb
index 464949e44bf..ffd2f7d0f21 100644
--- a/app/form_models/school_search_form.rb
+++ b/app/form_models/school_search_form.rb
@@ -19,7 +19,7 @@ def to_h
 
     if attrs.key?(:location)
       self.radius = Search::RadiusBuilder.new(attrs[:location], attrs[:radius]).radius.to_s
-      attrs[:location] = [attrs[:location], radius]
+      attrs[:radius] = radius
     else
       attrs.delete(:radius)
     end
@@ -42,7 +42,7 @@ def key_stage_options
   end
 
   def job_availability_options
-    [true, false].map { |i| [i.to_s, I18n.t(i, scope: "organisations.filters.job_availability.options")] }
+    [["true", I18n.t("organisations.filters.job_availability.options.true")]]
   end
 
   def organisation_type_options
diff --git a/app/services/search/school_search.rb b/app/services/search/school_search.rb
index 90901a3a222..ff3213bf53a 100644
--- a/app/services/search/school_search.rb
+++ b/app/services/search/school_search.rb
@@ -1,8 +1,8 @@
 class Search::SchoolSearch
   extend Forwardable
-  def_delegators :location_search, :point_coordinates
+  def_delegators :location_search, :point_coordinates, :polygon
 
-  attr_reader :search_criteria, :name, :location, :radius, :organisation_types, :school_types
+  attr_reader :search_criteria, :name, :location, :radius, :organisation_types, :school_types, :original_scope
 
   def initialize(search_criteria, scope: Organisation.all)
     @search_criteria = search_criteria
@@ -11,6 +11,7 @@ def initialize(search_criteria, scope: Organisation.all)
     @radius = search_criteria[:radius]
     @organisation_types = search_criteria[:organisation_types]
     @school_types = search_criteria[:school_types]
+    @original_scope = scope.where(scope.where_values_hash)
     @scope = scope
   end
 
@@ -23,10 +24,12 @@ def active_criteria?
     active_criteria.any?
   end
 
-  def wider_search_suggestions
-    return unless vacancies.empty? && search_criteria[:location].present?
+  def location_search
+    @location_search ||= Search::LocationBuilder.new(search_criteria[:location], search_criteria[:radius])
+  end
 
-    Search::WiderSuggestionsBuilder.new(search_criteria).suggestions
+  def wider_search_suggestions
+    @wider_search_suggestions ||= Search::WiderSuggestionsBuilder.call(self)
   end
 
   def organisations
@@ -34,7 +37,7 @@ def organisations
   end
 
   def total_count
-    schools.count
+    @total_count ||= organisations.count
   end
 
   def total_filters
@@ -52,7 +55,7 @@ def scope
     scope = @scope.all
 
     scope = scope.search_by_name(name) if name.present?
-    scope = scope.search_by_location(*location) if location.present?
+    scope = scope.search_by_location(location, radius, polygon:) if location
     scope = scope.where(phase: education_phase) if education_phase
     scope = scope.where(phase: key_stage_phases) if key_stage_phases
     scope = apply_organisation_type_filter(scope)
diff --git a/app/services/search/vacancy_search.rb b/app/services/search/vacancy_search.rb
index dd689ef9861..d3fb5a5acd9 100644
--- a/app/services/search/vacancy_search.rb
+++ b/app/services/search/vacancy_search.rb
@@ -2,7 +2,7 @@ class Search::VacancySearch
   extend Forwardable
   def_delegators :location_search, :point_coordinates, :polygon
 
-  attr_reader :search_criteria, :keyword, :location, :radius, :organisation_slug, :sort
+  attr_reader :search_criteria, :keyword, :location, :radius, :organisation_slug, :sort, :original_scope
 
   def initialize(search_criteria, sort: nil, scope: Vacancy.live)
     @search_criteria = search_criteria
@@ -11,6 +11,7 @@ def initialize(search_criteria, sort: nil, scope: Vacancy.live)
     @radius = search_criteria[:radius]
     @organisation_slug = search_criteria[:organisation_slug]
     @sort = sort || Search::VacancySort.new(keyword: keyword, location: location)
+    @original_scope = scope.where(scope.where_values_hash)
     @scope = scope
   end
 
@@ -36,9 +37,7 @@ def location_search
   end
 
   def wider_search_suggestions
-    return unless total_count.zero? && search_criteria[:location].present?
-
-    @wider_search_suggestions ||= Search::WiderSuggestionsBuilder.new(search_criteria).suggestions
+    @wider_search_suggestions ||= Search::WiderSuggestionsBuilder.call(self)
   end
 
   def organisation
diff --git a/app/services/search/wider_suggestions_builder.rb b/app/services/search/wider_suggestions_builder.rb
index a5ab268a8a2..6a8577cbe02 100644
--- a/app/services/search/wider_suggestions_builder.rb
+++ b/app/services/search/wider_suggestions_builder.rb
@@ -2,16 +2,20 @@ class Search::WiderSuggestionsBuilder
   include DistanceHelper
   include VacanciesOptionsHelper
 
-  attr_reader :search_criteria
+  attr_reader :search_criteria, :initial_radius, :initial_search
 
-  def initialize(search_criteria)
-    @search_criteria = search_criteria
-  end
+  def self.call(search_instance)
+    builder = new(search_instance)
+    return if builder.search_criteria[:location].blank?
+    return if builder.initial_search.total_count >= 1
 
-  def initial_radius
-    return Integer(search_criteria[:radius]) if search_criteria[:radius]
+    builder.suggestions
+  end
 
-    0
+  def initialize(initial_search)
+    @initial_search = initial_search
+    @search_criteria = initial_search.search_criteria
+    @initial_radius = initial_search.search_criteria[:radius].to_i
   end
 
   def suggestions
@@ -24,6 +28,9 @@ def suggestions
   private
 
   def wider_results_count(radius)
-    Search::VacancySearch.new(search_criteria.merge(radius: radius)).total_count
+    initial_search.class.new(
+      search_criteria.merge(radius: radius),
+      scope: initial_search.original_scope,
+    ).total_count
   end
 end
diff --git a/app/views/organisations/_organisation.html.slim b/app/views/organisations/_organisation.html.slim
index b740a7ee54a..26cca16ce8a 100644
--- a/app/views/organisations/_organisation.html.slim
+++ b/app/views/organisations/_organisation.html.slim
@@ -124,4 +124,4 @@
 
     p.govuk-body = t("organisations.show.job_alert.call_to_action", organisation_name: organisation.name)
 
-    = govuk_button_link_to(t("organisations.show.job_alert.button"), new_subscription_path(search_criteria: { organisation_slug: organisation.slug }), secondary: true)
+    = govuk_button_link_to(t("organisations.show.job_alert.button"), new_subscription_path(search_criteria: { organisation_slug: organisation.slug }))
diff --git a/app/views/organisations/index.html.slim b/app/views/organisations/index.html.slim
index 14b44e088b1..cc4744acc44 100644
--- a/app/views/organisations/index.html.slim
+++ b/app/views/organisations/index.html.slim
@@ -15,11 +15,7 @@ h1 class="govuk-heading-l" role="heading" aria-level="1"
         - if @schools.any?
           = render "organisations/search/results", schools: @schools
         - else
-          p.govuk-heading-m = t("organisations.search.no_results_heading")
-          p = t("organisations.search.no_results_text")
-          ul.govuk-list.govuk-list--bullet
-            - t("organisations.search.no_results_hints").each do |hint|
-              li = hint
+          = render "organisations/search/wider_search"
 
       = govuk_pagination(pagy: @pagy)
 
diff --git a/app/views/organisations/search/_wider_search.html.slim b/app/views/organisations/search/_wider_search.html.slim
new file mode 100644
index 00000000000..0f955be5e5d
--- /dev/null
+++ b/app/views/organisations/search/_wider_search.html.slim
@@ -0,0 +1,17 @@
+.divider-bottom
+  .govuk-heading-m = t("organisations.search.no_results_heading")
+  p.govuk-body
+    = t("organisations.search.no_results_text")
+    ul.govuk-list.govuk-list--bullet
+      - t("organisations.search.no_results_hints").each do |hint|
+        li = hint
+
+- if @school_search.wider_search_suggestions.present?
+  .divider-bottom
+    ul.govuk-list.govuk-list--bullet
+      - @school_search.wider_search_suggestions.each do |wider_radius|
+        li = t(".wider_search_suggestions.suggestion_html",
+               search_link: govuk_link_to(t(".wider_search_suggestions.radius_distance", radius: wider_radius.first),
+                 organisations_path(@school_search.search_criteria.merge(radius: wider_radius.first)),
+                 visually_hidden_suffix: t(".wider_search_suggestions.hidden", location: @school_search.location)),
+               count: t(".wider_search_suggestions.results", count: wider_radius.last))
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 90a14fde351..11914c8de67 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -271,7 +271,7 @@ en:
         page_title: Schools - %{school_group_name}
     search:
       name: Name of school
-      location: City, county or postcode
+      location: City, county or postcode (in England)
       results:
         active_jobs: Active jobs
         key_stage: Key stage
diff --git a/config/locales/organisations.yml b/config/locales/organisations.yml
index bad63f2e938..c1c60b9d897 100644
--- a/config/locales/organisations.yml
+++ b/config/locales/organisations.yml
@@ -12,11 +12,21 @@ en:
       job_availability:
         label: Job availability
         options:
-          true: Has jobs
+          true: Only show school with jobs available
           false: Does not have any jobs
     search:
-      no_results_heading: No schools found
+      no_results_heading: Try another search
       no_results_text: "You may get more results if you:"
       no_results_hints:
         - change the radius of your search
         - look at a wider geographical location, for example West Yorkshire instead of Leeds
+        - remove some filters if you have used them
+      wider_search:
+        wider_search_suggestions:
+          suggestion_html: '%{search_link} %{count}'
+          radius_distance: '%{radius} miles'
+          hidden: 'from %{location}'
+          results:
+            one: (%{count} result)
+            other: (%{count} results)
+
diff --git a/spec/form_models/school_search_form_spec.rb b/spec/form_models/school_search_form_spec.rb
new file mode 100644
index 00000000000..bb05f01adba
--- /dev/null
+++ b/spec/form_models/school_search_form_spec.rb
@@ -0,0 +1,82 @@
+require "rails_helper"
+
+RSpec.describe SchoolSearchForm, type: :model do
+  let(:school_search_form) { described_class.new(params) }
+  let(:params) do
+    {
+      location: location,
+      radius: radius,
+      education_phase: education_phase,
+      key_stage: key_stage,
+      special_school: special_school,
+      job_availability: job_availability,
+      organisation_types: organisation_types,
+      school_types: school_types,
+    }
+  end
+
+  let(:location) { nil }
+  let(:radius) { nil }
+  let(:education_phase) { [] }
+  let(:key_stage) { [] }
+  let(:special_school) { [] }
+  let(:job_availability) { [] }
+  let(:organisation_types) { [] }
+  let(:school_types) { [] }
+
+  describe "#job_availability_options" do
+    subject { school_search_form.job_availability_options }
+
+    let(:expected_options) do
+      [["true", I18n.t(true, scope: "organisations.filters.job_availability.options")]]
+    end
+
+    it { is_expected.to match_array(expected_options) }
+  end
+
+  describe "#to_h" do
+    subject { school_search_form.to_h }
+
+    context "when no params" do
+      let(:expected_hash) { {} }
+
+      it { is_expected.to eq(expected_hash) }
+    end
+
+    context "when location present" do
+      let(:location) { "Nottingham" }
+      let(:expected_hash) do
+        {
+          location: location,
+          radius: 15,
+        }
+      end
+
+      it { is_expected.to eq(expected_hash) }
+    end
+
+    context "when location and radius present" do
+      let(:location) { "Nottingham" }
+      let(:radius) { 5 }
+      let(:expected_hash) do
+        {
+          location: location,
+          radius: radius,
+        }
+      end
+
+      it { is_expected.to eq(expected_hash) }
+    end
+
+    context "when job_availability present" do
+      let(:job_availability) { %w[true] }
+      let(:expected_hash) do
+        {
+          job_availability: job_availability,
+        }
+      end
+
+      it { is_expected.to eq(expected_hash) }
+    end
+  end
+end
diff --git a/spec/services/search/school_search_spec.rb b/spec/services/search/school_search_spec.rb
index c10616e48c8..79cb0b99ded 100644
--- a/spec/services/search/school_search_spec.rb
+++ b/spec/services/search/school_search_spec.rb
@@ -6,7 +6,8 @@
   let(:form_hash) do
     {
       name: name,
-      location: ([location, radius] if location.present?),
+      location: location,
+      radius: radius,
       organisation_types: organisation_types,
       key_stage: key_stage,
       job_availability: job_availability,
@@ -24,6 +25,27 @@
 
   let(:scope) { School.all }
 
+  describe "#wider_search_suggestions" do
+    let(:school_search) { described_class.new(form_hash, scope: scope) }
+    let(:builder) { Search::WiderSuggestionsBuilder }
+
+    before do
+      allow(builder).to receive(:call)
+    end
+
+    it "uses Search::WiderSuggestionsBuilder to provide suggestions" do
+      school_search.wider_search_suggestions
+      expect(builder).to have_received(:call).with(school_search)
+    end
+  end
+
+  describe "#location_search" do
+    it "uses Search::LocationBuilder" do
+      expect(Search::LocationBuilder).to receive(:new).with(location, radius)
+      subject.location_search
+    end
+  end
+
   context "when no filters (except for radius) are given" do
     it "returns unmodified scope" do
       expect(subject.organisations.to_sql).to eq(scope.to_sql)
@@ -137,8 +159,8 @@
     let(:school_types) { ["special_school"] }
 
     it "clears selected filters " do
-      expect(subject.active_criteria).to eq({ location: ["Sevenoaks", 10], name: name, organisation_types: organisation_types, job_availability: job_availability, key_stage: key_stage, school_types: school_types })
-      expect(subject.clear_filters_params).to eq({ location: ["Sevenoaks", 10], name: name, organisation_types: [], education_phase: [], key_stage: [], job_availability: [], school_types: [] })
+      expect(subject.active_criteria).to eq({ location: "Sevenoaks", radius: 10, name: name, organisation_types: organisation_types, job_availability: job_availability, key_stage: key_stage, school_types: school_types })
+      expect(subject.clear_filters_params).to eq({ location: "Sevenoaks", radius: 10, name: name, organisation_types: [], education_phase: [], key_stage: [], job_availability: [], school_types: [] })
     end
   end
 end
diff --git a/spec/services/search/vacancy_search_spec.rb b/spec/services/search/vacancy_search_spec.rb
index 217ec50c8c9..a82b44de980 100644
--- a/spec/services/search/vacancy_search_spec.rb
+++ b/spec/services/search/vacancy_search_spec.rb
@@ -44,6 +44,20 @@
     allow(subject).to receive(:organisation).and_return(school)
   end
 
+  describe "#wider_search_suggestions" do
+    let(:vacancy_search) { described_class.new(form_hash) }
+    let(:builder) { Search::WiderSuggestionsBuilder }
+
+    before do
+      allow(builder).to receive(:call)
+      vacancy_search.wider_search_suggestions
+    end
+
+    it "uses Search::WiderSuggestionsBuilder to provide suggestions" do
+      expect(builder).to have_received(:call).with(vacancy_search)
+    end
+  end
+
   context "when clearing filters" do
     let(:teaching_job_roles) { ["teacher"] }
     let(:ect_statuses) { ["ect_suitable"] }
diff --git a/spec/services/search/wider_suggestions_builder_spec.rb b/spec/services/search/wider_suggestions_builder_spec.rb
index abf5053031e..b0cd57f6150 100644
--- a/spec/services/search/wider_suggestions_builder_spec.rb
+++ b/spec/services/search/wider_suggestions_builder_spec.rb
@@ -1,34 +1,84 @@
 require "rails_helper"
 
 RSpec.describe Search::WiderSuggestionsBuilder do
-  subject { described_class.new(search_params) }
+  subject { described_class.new(initial_search) }
 
   let(:search_params) do
     {
       radius: radius,
       keyword: "test",
-      location: "somewhere",
+      location: location,
       filters: [],
       sort_by: nil,
     }
   end
-
+  let(:radius) { 6 }
+  let(:location) { "somewhere" }
+  let(:initial_search) { Search::VacancySearch.new(search_params) }
   let(:pg_search) { double("search") }
 
+  before do
+    subject
+    allow(pg_search).to receive(:total_count).and_return(0, 0, 2, 4, 15, 80, 80)
+  end
+
+  describe ".call" do
+    let(:suggestions) { described_class.call(initial_search) }
+
+    context "when initial_search is missing the search_criteria[:location] then suggestion search is not allowed" do
+      let(:location) { nil }
+
+      it { expect(suggestions).to be_nil }
+    end
+
+    context "when initial_search has some results then suggestion search is not allowed" do
+      before { allow(initial_search).to receive(:total_count).and_return(1) }
+
+      it { expect(described_class.call(initial_search)).to be_nil }
+    end
+
+    context "returns suggestions" do
+      let(:expected_suggestions) do
+        [
+          ["20", 2],
+          ["25", 4],
+          ["50", 15],
+          ["100", 80],
+        ]
+      end
+
+      before do
+        [10, 15, 20, 25, 50, 100, 200].each do |radius|
+          allow(initial_search.class)
+            .to receive(:new)
+                  .with(hash_including(radius:), scope: kind_of(ActiveRecord::Relation))
+                  .and_return(pg_search)
+        end
+      end
+
+      context "when initial_search is a Search::VacancySearch" do
+        let(:initial_search) { Search::VacancySearch.new(search_params) }
+
+        it { expect(suggestions).to eq(expected_suggestions) }
+      end
+
+      context "when initial_search is a Search::SchoolSearch" do
+        let(:initial_search) { Search::SchoolSearch.new(search_params) }
+
+        it { expect(suggestions).to eq(expected_suggestions) }
+      end
+    end
+  end
+
   describe "#suggestions" do
     context "given a radius" do
-      let(:radius) { 6 }
-
       it "provides radius suggestions beyond the current radius" do
-        expect(Search::VacancySearch).to receive(:new).with(hash_including(radius: 10)).and_return(pg_search)
-        expect(Search::VacancySearch).to receive(:new).with(hash_including(radius: 15)).and_return(pg_search)
-        expect(Search::VacancySearch).to receive(:new).with(hash_including(radius: 20)).and_return(pg_search)
-        expect(Search::VacancySearch).to receive(:new).with(hash_including(radius: 25)).and_return(pg_search)
-        expect(Search::VacancySearch).to receive(:new).with(hash_including(radius: 50)).and_return(pg_search)
-        expect(Search::VacancySearch).to receive(:new).with(hash_including(radius: 100)).and_return(pg_search)
-        expect(Search::VacancySearch).to receive(:new).with(hash_including(radius: 200)).and_return(pg_search)
-
-        allow(pg_search).to receive(:total_count).and_return(0, 0, 2, 4, 15, 80, 80)
+        [10, 15, 20, 25, 50, 100, 200].each do |radius|
+          expect(initial_search.class)
+            .to receive(:new)
+                  .with(hash_including(radius:), scope: kind_of(ActiveRecord::Relation))
+                  .and_return(pg_search)
+        end
 
         expect(subject.suggestions).to eq([
           ["20", 2],


Reviews:
user: login: starswan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T14:31:42Z
user: login: starswan
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-13T14:34:41Z
user: login: jriga
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-14T16:33:05Z
user: login: jriga
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T09:53:29Z
user: login: starswan
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T10:06:00Z

Review comments:
diff_hunk: @@ -1,3 +1,109 @@
 #!/usr/bin/env ruby
-ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
-load Gem.bin_path('bundler', 'bundle')
+# frozen_string_literal: true
+
path: bin/bundle
user: login: starswan
body: not sure this should by part of this PR?
created_at: 2025-03-13T14:31:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -42,7 +42,7 @@ def key_stage_options
   end
 
   def job_availability_options
-    [true, false].map { |i| [i.to_s, I18n.t(i, scope: "organisations.filters.job_availability.options")] }
+    [true].map { |i| [i.to_s, I18n.t(i, scope: "organisations.filters.job_availability.options")] }
path: app/form_models/school_search_form.rb
user: login: starswan
body: this now looks strange - could we not remove this method and replace its usage with I18t("organisations.filters.job_availability.options.true") ?
created_at: 2025-03-13T14:34:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -42,7 +42,7 @@ def key_stage_options
   end
 
   def job_availability_options
-    [true, false].map { |i| [i.to_s, I18n.t(i, scope: "organisations.filters.job_availability.options")] }
+    [true].map { |i| [i.to_s, I18n.t(i, scope: "organisations.filters.job_availability.options")] }
path: app/form_models/school_search_form.rb
user: login: jriga
body: :recycle: 
created_at: 2025-03-14T16:33:05Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,3 +1,109 @@
 #!/usr/bin/env ruby
-ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__)
-load Gem.bin_path('bundler', 'bundle')
+# frozen_string_literal: true
+
path: bin/bundle
user: login: jriga
body: moved out to its out PR  #7633 
created_at: 2025-03-19T09:53:29Z
author_association: CONTRIBUTOR

PR comments:
user: login: jriga
created_at: 2025-03-13T14:14:16Z
body: ![](https://github.trello.services/images/mini-trello-icon.png) [Schools page bug party fixes](https://trello.com/c/paDj2CP3/1613-schools-page-bug-party-fixes)
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-17T16:05:07Z
body: Review app <https://teaching-vacancies-review-pr-7616.test.teacherservices.cloud> was successfully deleted
<!-- Sticky Pull Request Comment -->
author_association: NONE