From 830d27fdaff0fabd6f2e448ff95ce70bfe4bc6ea Mon Sep 17 00:00:00 2001 From: Lori Bailey <44073106+elceebee@users.noreply.github.com> Date: Tue, 7 Jan 2025 14:14:05 +0000 Subject: [PATCH] Implement new withdrawal reasons report, interstitial page and new content --- .../withdrawal_reasons_reports_controller.rb | 1 + app/frontend/styles/provider/_all.scss | 1 + .../provider/_withdrawal_reasons_report.scss | 34 +++++ app/models/withdrawal_reason.rb | 20 ++- ...ate_withdrawal_reasons_data_by_provider.rb | 135 ++++++++++++++++++ .../provider_interface/reports/index.html.erb | 6 +- .../withdrawal_reasons_reports/show.html.erb | 42 ++++++ .../reports/withdrawal_reports/index.html.erb | 15 +- .../reports/withdrawal_reports/show.html.erb | 36 +---- config/locales/en.yml | 1 - config/locales/provider_interface/reports.yml | 29 ++++ ...ithdrawal_reasons_data_by_provider_spec.rb | 64 +++++++++ .../index_provider_user_one_provider_spec.rb | 5 + .../index_provider_user_two_providers_spec.rb | 5 + ..._providers_with_performance_report_spec.rb | 4 + ...er_views_withdrawal_reasons_report_spec.rb | 54 +++++++ 16 files changed, 404 insertions(+), 48 deletions(-) create mode 100644 app/frontend/styles/provider/_withdrawal_reasons_report.scss create mode 100644 app/services/provider_interface/candidate_withdrawal_reasons_data_by_provider.rb create mode 100644 spec/services/provider_interface/candidate_withdrawal_reasons_data_by_provider_spec.rb create mode 100644 spec/system/provider_interface/reports/provider_views_withdrawal_reasons_report_spec.rb diff --git a/app/controllers/provider_interface/reports/withdrawal_reasons_reports_controller.rb b/app/controllers/provider_interface/reports/withdrawal_reasons_reports_controller.rb index fe2a9022bab..edd3e7e0e58 100644 --- a/app/controllers/provider_interface/reports/withdrawal_reasons_reports_controller.rb +++ b/app/controllers/provider_interface/reports/withdrawal_reasons_reports_controller.rb @@ -5,6 +5,7 @@ class WithdrawalReasonsReportsController < ProviderInterfaceController def show @provider = current_user.providers.find(provider_id) + @withdrawal_reason_report = ProviderInterface::CandidateWithdrawalReasonsDataByProvider.new(@provider) end private diff --git a/app/frontend/styles/provider/_all.scss b/app/frontend/styles/provider/_all.scss index 2c6be65a718..76c65c4e4f2 100644 --- a/app/frontend/styles/provider/_all.scss +++ b/app/frontend/styles/provider/_all.scss @@ -16,3 +16,4 @@ @import "user-card"; @import "email-preview"; @import "recruitment_performance_report"; +@import "withdrawal_reasons_report"; diff --git a/app/frontend/styles/provider/_withdrawal_reasons_report.scss b/app/frontend/styles/provider/_withdrawal_reasons_report.scss new file mode 100644 index 00000000000..819d3179eeb --- /dev/null +++ b/app/frontend/styles/provider/_withdrawal_reasons_report.scss @@ -0,0 +1,34 @@ +.withdrawal-reasons-report-table { + &__wrapper { + overflow-x: auto; + } + + &__heading { + border-bottom: none; + } + + &__cell--main-reason { + font-weight: $govuk-font-weight-bold; + + background: govuk-colour("light-grey"); + border-top: 2px solid $govuk-input-border-colour; + } + + &__cell--second-level-reason { + padding-left: govuk-spacing(3); + } + + &__cell--third-level-reason { + padding-left: govuk-spacing(5); + } + + &__cell--second-level-with-nested-reasons { + padding-left: govuk-spacing(3); + + background: govuk-tint(govuk-colour('light-grey'), 50); + } + + &__cell--light-grey-background { + background: govuk-tint(govuk-colour('light-grey'), 50); + } +} diff --git a/app/models/withdrawal_reason.rb b/app/models/withdrawal_reason.rb index cfcccb4e8b7..c02c0e03dd4 100644 --- a/app/models/withdrawal_reason.rb +++ b/app/models/withdrawal_reason.rb @@ -7,11 +7,7 @@ class WithdrawalReason < ApplicationRecord scope :by_level_one_reason, lambda { |level| keys = get_reason_options(level).map do |key, value| - if value == {} - key - else - value.map { |val_key, _| "#{key}.#{val_key}" } - end + build_reason(key, value) end&.flatten where('reason LIKE ?', "#{level}%").sort do |a, b| @@ -39,4 +35,18 @@ def self.get_reason_options(reason = '') selectable_reasons.dig(*reason.split('.')) end end + + def self.all_reasons + selectable_reasons.map do |key, value| + build_reason(key, value) + end&.flatten + end + + def self.build_reason(key, value) + if value == {} + key + else + value.map { |k, v| build_reason("#{key}.#{k}", v) } + end + end end diff --git a/app/services/provider_interface/candidate_withdrawal_reasons_data_by_provider.rb b/app/services/provider_interface/candidate_withdrawal_reasons_data_by_provider.rb new file mode 100644 index 00000000000..86219919564 --- /dev/null +++ b/app/services/provider_interface/candidate_withdrawal_reasons_data_by_provider.rb @@ -0,0 +1,135 @@ +module ProviderInterface + class CandidateWithdrawalReasonsDataByProvider + def initialize(provider) + @provider = provider + end + + ReasonRow = Struct.new(:reason, :before_accepting, :after_accepting, :total) + + def show_report? + all_rows.any? + end + + def all_rows + return [] if application_form_count < ProviderReports::MINIMUM_DATA_SIZE_REQUIRED + + rows = [] + nested_reasons.each_key do |level_one_reason| + rows << build_reason_row(level_one_reason, 'level_one') + + nested_reasons[level_one_reason].each_key do |level_two_reason| + full_level_two_reason = [level_one_reason, level_two_reason].join('.') + + if nested_reasons[level_one_reason][level_two_reason].present? + rows << build_reason_row(full_level_two_reason, 'level_two_with_nested_reasons') + nested_reasons[level_one_reason][level_two_reason].each_key do |level_three_reason| + full_level_three_reason = [level_one_reason, level_two_reason, level_three_reason].join('.') + rows << build_reason_row(full_level_three_reason, 'level_three') + end + else + rows << build_reason_row(full_level_two_reason, 'level_two') + end + end + end + rows + end + + private + + def build_reason_row(reason, level) + ReasonRow.new( + reason: { + text: translate(reason), + html_attributes: text_cell_attributes_for(level), + }, + before_accepting: { + text: before_accepting_count(reason), + numeric: true, + html_attributes: numeric_cell_attributes_for(level), + }, + after_accepting: { + text: after_accepting_count(reason), + numeric: true, + html_attributes: numeric_cell_attributes_for(level), + }, + total: { + text: before_accepting_count(reason) + after_accepting_count(reason), + numeric: true, + html_attributes: numeric_cell_attributes_for(level), + }, + ) + end + + def text_cell_attributes_for(level) + case level + when 'level_one' + { class: 'withdrawal-reasons-report-table__cell--main-reason' } + when 'level_two' + { class: 'withdrawal-reasons-report-table__cell--second-level-reason' } + when 'level_two_with_nested_reasons' + { class: 'withdrawal-reasons-report-table__cell--second-level-with-nested-reasons' } + when 'level_three' + { class: 'withdrawal-reasons-report-table__cell--third-level-reason' } + else + {} + end + end + + def numeric_cell_attributes_for(level) + case level + when 'level_one' + { class: 'withdrawal-reasons-report-table__cell--main-reason' } + when 'level_two_with_nested_reasons' + { class: 'withdrawal-reasons-report-table__cell--light-grey-background' } + else + {} + end + end + + def translate(string) + translation_string = string.dup.gsub('-', '_') + I18n.t("candidate_interface.withdrawal_reasons.reasons.#{translation_string}.label") + end + + def before_accepting_count(reason) + withdrawal_reasons_before_acceptance.filter { |r| r.starts_with?(reason) }.length + end + + def after_accepting_count(reason) + withdrawal_reasons_after_acceptance.filter { |r| r.starts_with?(reason) }.length + end + + def nested_reasons + @nested_reasons ||= WithdrawalReason.selectable_reasons + end + + def withdrawal_reasons_before_acceptance + @withdrawal_reasons_before_acceptance ||= + withdrawal_reasons + .where(application_choices: { accepted_at: nil }) + .uniq + .pluck(:reason) + end + + def withdrawal_reasons_after_acceptance + @withdrawal_reasons_after_acceptance ||= + withdrawal_reasons + .where.not(application_choices: { accepted_at: nil }) + .uniq + .pluck(:reason) + end + + def withdrawal_reasons + @withdrawal_reasons ||= + WithdrawalReason + .joins(:application_choice) + .published + .where('application_choices.provider_ids @> ARRAY[?]::bigint[]', @provider.id) + .where(application_choices: { current_recruitment_cycle_year: RecruitmentCycle.current_year }) + end + + def application_form_count + withdrawal_reasons.select('application_choices.application_form_id').distinct.count + end + end +end diff --git a/app/views/provider_interface/reports/index.html.erb b/app/views/provider_interface/reports/index.html.erb index 335bec749ce..a5b1a621103 100644 --- a/app/views/provider_interface/reports/index.html.erb +++ b/app/views/provider_interface/reports/index.html.erb @@ -35,7 +35,11 @@ <%= govuk_link_to t('page_titles.provider.diversity_report'), provider_interface_reports_provider_diversity_report_path(provider_id: provider) %>
+ <%= t('.report_description') %> +
+ <% else %> + <%= t('.report_not_visible_html', link: govuk_link_to(t('.withdrawal_report_link_text'), provider_interface_reports_provider_withdrawal_report_path(@provider))) %> + <% end %> + + + <% if @withdrawal_reason_report.show_report? %> +- TBD, some text about why there are two different reports -
+ <%= t('.description_html') %> <% @providers.each do |provider| %> <% if @providers.many? %> @@ -18,23 +15,19 @@- You will be able to see this report once it contains data from at least <%= ProviderReports::MINIMUM_DATA_SIZE_REQUIRED %> candidates. This is to protect the privacy of candidates. -
-- The report shows data from candidates who withdrew their application and selected their reason from a set list. This is an optional question. Data for this report has only been collected from 11 April 2023. -
- <% else %> -Candidates who withdraw their application are asked to select their reasons for withdrawing. This is an optional question.
-Candidates are asked this question when they withdraw:
-- Candidates who have received an offer are not able to withdraw – they have to either accept or decline instead. -
-- Candidates can select multiple reasons, so the numbers in each column may not match the ‘Total’ number. -
-- This data has only been collected from 11 April 2023. -
+ <%= t('.description_html', link: govuk_link_to(t('.withdrawal_reasons_report_link_text'), provider_interface_reports_provider_withdrawal_reasons_report_path(@provider))) %>This report shows the reasons for withdrawal selected by candidates from a set list.
+In January 2025 we made it mandatory for candidates to provide at least one reason for withdrawing. We also added some new reasons for candidates to select from.
+Because the new reasons do not map exactly to the old reasons, it is not possible to combine withdrawal data from before January 2025 with withdrawal data from after that date in a single report.
+ legacy_withdrawal_report: 'Withdrawal reasons: up to January 2025' + withdrawal_reasons_report: 'Withdrawal reasons: from January 2025' + show: + heading: 'Withdrawal reasons: October' + description_html: +You will be able to see this report if it contains data from at least 10 candidates. This is to protect the privacy of candidates.
+The report shows data from candidates who withdrew their application and selected their reason from a set list. This was an optional question, candidates who withdrew their application but did not select a reason are not represented in this report.
+Data for this report was collected between October 2024 and January 2025.
+Go to the %{link} report to see more recent withdrawal data.
+ withdrawal_reasons_report_link_text: 'withdrawal reasons: from January 2025' + withdrawal_reasons_reports: + show: + report_description: This report shows the reasons for withdrawal selected by candidates from a set list. The question is mandatory and candidates can select more than one reason. + report_not_visible_html: +You will be able to see this report when it contains data from at least 10 candidates. This is to protect the privacy of candidates. Candidates can select more than one reason for withdrawing so the number of reasons in this report may not match the number of candidates who have withdrawn.
+The report shows data from candidates who withdrew their application and selected their reason from a set list. This question is mandatory.
+Data for this report has been collected since 13 January 2025. Go to %{link} to see withdrawal data from before this date.
+ withdrawal_report_link_text: 'withdrawal reasons: up to January 2025' + table_caption: Withdrawal reason report data + withdrawal_reason: Withdrawal reason + before_accepting: Before accepting + after_accepting: After accepting + total: Total diff --git a/spec/services/provider_interface/candidate_withdrawal_reasons_data_by_provider_spec.rb b/spec/services/provider_interface/candidate_withdrawal_reasons_data_by_provider_spec.rb new file mode 100644 index 00000000000..3aa4e12e7ac --- /dev/null +++ b/spec/services/provider_interface/candidate_withdrawal_reasons_data_by_provider_spec.rb @@ -0,0 +1,64 @@ +require 'rails_helper' + +RSpec.describe ProviderInterface::CandidateWithdrawalReasonsDataByProvider do + let(:provider) { create(:provider) } + + describe '#all_rows' do + context 'when there are fewer then 10 withdrawals from unique application forms' do + it 'returns an empty array' do + application_forms = create_list(:application_form, 9) + application_forms.each do |application_form| + application_choice = create(:application_choice, :withdrawn, application_form:, provider_ids: [provider.id]) + create(:withdrawal_reason, status: 'published', application_choice:, reason: WithdrawalReason.all_reasons.sample) + end + + expect(described_class.new(provider).all_rows).to eq [] + end + end + + context 'when there are at least 10 withdrawals from unique application forms' do + it 'returns data for the report' do + create_list(:application_form, 10).each do |application_form| + application_choice = create(:application_choice, :withdrawn, application_form:, provider_ids: [provider.id]) + create(:withdrawal_reason, status: 'published', application_choice:, reason: 'applying-to-another-provider.accepted-another-offer') + end + + rows = described_class.new(provider).all_rows + + main_reason_row = rows.find { |row| row.reason[:text] == 'I am going to apply (or have applied) to a different training provider' } + expect(main_reason_row.total[:text]).to eq 10 + + level_two_reason_row = rows.find { |row| row.reason[:text] == 'I have accepted another offer' } + expect(level_two_reason_row.total[:text]).to eq 10 + end + end + + context 'where there is a mix of withdrawals before and after the candidate has accepted an offer' do + it 'returns data for the report' do + # rubocop:disable Style/CombinableLoops + create_list(:application_form, 5).each do |application_form| + accepted_application_choice = create(:application_choice, :accepted, application_form:, provider_ids: [provider.id]) + create(:withdrawal_reason, status: 'published', application_choice: accepted_application_choice, reason: 'applying-to-another-provider.accepted-another-offer') + end + + create_list(:application_form, 5).each do |application_form| + not_accepted_application_choice = create(:application_choice, application_form: application_form, provider_ids: [provider.id]) + create(:withdrawal_reason, status: 'published', application_choice: not_accepted_application_choice, reason: 'applying-to-another-provider.accepted-another-offer') + end + # rubocop:enable Style/CombinableLoops + + rows = described_class.new(provider).all_rows + main_reason_row = rows.find { |row| row.reason[:text] == 'I am going to apply (or have applied) to a different training provider' } + + expect(main_reason_row.total[:text]).to eq 10 + expect(main_reason_row.before_accepting[:text]).to eq 5 + expect(main_reason_row.after_accepting[:text]).to eq 5 + + level_two_reason_row = rows.find { |row| row.reason[:text] == 'I have accepted another offer' } + expect(level_two_reason_row.total[:text]).to eq 10 + expect(level_two_reason_row.before_accepting[:text]).to eq 5 + expect(level_two_reason_row.after_accepting[:text]).to eq 5 + end + end + end +end diff --git a/spec/system/provider_interface/reports/index_provider_user_one_provider_spec.rb b/spec/system/provider_interface/reports/index_provider_user_one_provider_spec.rb index 71fac3aff6b..e60429b3581 100644 --- a/spec/system/provider_interface/reports/index_provider_user_one_provider_spec.rb +++ b/spec/system/provider_interface/reports/index_provider_user_one_provider_spec.rb @@ -2,6 +2,11 @@ RSpec.describe 'Provider reports index' do include DfESignInHelpers + + before do + FeatureFlag.deactivate :new_candidate_withdrawal_reasons + end + scenario 'when provider user has one provider' do given_a_provider_and_provider_user_exists and_i_am_signed_in_as_provider_user diff --git a/spec/system/provider_interface/reports/index_provider_user_two_providers_spec.rb b/spec/system/provider_interface/reports/index_provider_user_two_providers_spec.rb index 976a497a79f..37a9fa04830 100644 --- a/spec/system/provider_interface/reports/index_provider_user_two_providers_spec.rb +++ b/spec/system/provider_interface/reports/index_provider_user_two_providers_spec.rb @@ -2,6 +2,11 @@ RSpec.describe 'Provider with two providers reports index' do include DfESignInHelpers + + before do + FeatureFlag.deactivate(:new_candidate_withdrawal_reasons) + end + scenario 'when a provider user has more than one provider' do given_a_provider_user_with_two_providers_exists and_i_am_signed_in_as_provider_user diff --git a/spec/system/provider_interface/reports/index_provider_user_two_providers_with_performance_report_spec.rb b/spec/system/provider_interface/reports/index_provider_user_two_providers_with_performance_report_spec.rb index 172d901a441..73fd1a455b3 100644 --- a/spec/system/provider_interface/reports/index_provider_user_two_providers_with_performance_report_spec.rb +++ b/spec/system/provider_interface/reports/index_provider_user_two_providers_with_performance_report_spec.rb @@ -3,6 +3,10 @@ RSpec.describe 'Provider with two providers reports index' do include DfESignInHelpers + before do + FeatureFlag.deactivate :new_candidate_withdrawal_reasons + end + scenario 'when provider user has multiple provider with performance report', time: mid_cycle(2024) do given_a_provider_user_with_two_providers_exists and_a_provider_has_a_recruitment_performance_report diff --git a/spec/system/provider_interface/reports/provider_views_withdrawal_reasons_report_spec.rb b/spec/system/provider_interface/reports/provider_views_withdrawal_reasons_report_spec.rb new file mode 100644 index 00000000000..efc8c69ccd0 --- /dev/null +++ b/spec/system/provider_interface/reports/provider_views_withdrawal_reasons_report_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +RSpec.describe 'Provider views withdrawal reports' do + include DfESignInHelpers + + before do + FeatureFlag.activate(:new_candidate_withdrawal_reasons) + end + + scenario 'Provider navigates to report where fewer than 10 candidates have provided withdrawal reasons' do + given_some_candidates_have_withdrawn_applications(9) + and_i_sign_in_as_a_provider_user + when_i_navigate_to_the_withdrawal_reasons_report + then_i_see_the_report_without_data + end + + scenario 'Provider navigates to the report where at least 10 candidates have provided withdrawal reasons' do + given_some_candidates_have_withdrawn_applications(10) + and_i_sign_in_as_a_provider_user + when_i_navigate_to_the_withdrawal_reasons_report + then_i_see_the_report_with_data + end + +private + + def given_some_candidates_have_withdrawn_applications(number_of_applications) + provider_user = create(:provider_user, :with_dfe_sign_in, email_address: 'email@provider.ac.uk') + provider = provider_user.providers.first + application_forms = create_list(:application_form, number_of_applications) + application_forms.each do |application_form| + application_choice = create(:application_choice, :withdrawn, application_form: application_form, provider_ids: [provider.id]) + create(:withdrawal_reason, status: 'published', application_choice:, reason: WithdrawalReason.all_reasons.sample) + end + end + + def and_i_sign_in_as_a_provider_user + provider_exists_in_dfe_sign_in + provider_signs_in_using_dfe_sign_in + end + + def when_i_navigate_to_the_withdrawal_reasons_report + click_on 'Reports' + click_on 'Withdrawals' + click_on 'Withdrawal reasons: from January 2025' + end + + def then_i_see_the_report_without_data + expect(page).to have_content 'You will be able to see this report when it contains data from at least 10 candidates.' + end + + def then_i_see_the_report_with_data + expect(page).to have_content 'This report shows the reasons for withdrawal selected by candidates from a set list. The question is mandatory and candidates can select more than one reason.' + end +end