diff --git a/Gemfile b/Gemfile index c249007c2..fa743fa4a 100644 --- a/Gemfile +++ b/Gemfile @@ -25,6 +25,10 @@ gem "govuk_design_system_formbuilder" gem "hotwire-rails" # Soft delete ActiveRecords objects gem "discard" +# Administration framework +gem "activeadmin" +# Admin charts +gem "chartkick" group :development, :test do # Call 'byebug' anywhere in the code to stop execution and get a debugger console diff --git a/Gemfile.lock b/Gemfile.lock index 8abc24776..386f2d68b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -86,6 +86,15 @@ GEM erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) + activeadmin (2.9.0) + arbre (~> 1.2, >= 1.2.1) + formtastic (>= 3.1, < 5.0) + formtastic_i18n (~> 0.4) + inherited_resources (~> 1.7) + jquery-rails (~> 4.2) + kaminari (~> 1.0, >= 1.2.1) + railties (>= 5.2, < 6.2) + ransack (~> 2.1, >= 2.1.1) activejob (6.1.4.1) activesupport (= 6.1.4.1) globalid (>= 0.3.6) @@ -109,6 +118,9 @@ GEM zeitwerk (~> 2.3) addressable (2.8.0) public_suffix (>= 2.0.2, < 5.0) + arbre (1.4.0) + activesupport (>= 3.0.0, < 6.2) + ruby2_keywords (>= 0.0.2, < 1.0) ast (2.4.2) bindex (0.8.1) bootsnap (1.9.1) @@ -124,6 +136,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) + chartkick (4.1.0) childprocess (4.1.0) coderay (1.1.3) concurrent-ruby (1.1.9) @@ -148,6 +161,9 @@ GEM factory_bot (~> 6.2.0) railties (>= 5.0.0) ffi (1.15.4) + formtastic (4.0.0) + actionpack (>= 5.2.0) + formtastic_i18n (0.7.0) globalid (0.5.2) activesupport (>= 5.0) govuk-components (2.1.4) @@ -159,15 +175,39 @@ GEM activemodel (>= 6.0) activesupport (>= 6.0) deep_merge (~> 1.2.1) + has_scope (0.8.0) + actionpack (>= 5.2) + activesupport (>= 5.2) hotwire-rails (0.1.3) rails (>= 6.0.0) stimulus-rails turbo-rails i18n (1.8.10) concurrent-ruby (~> 1.0) + inherited_resources (1.13.0) + actionpack (>= 5.2, < 6.2) + has_scope (~> 0.6) + railties (>= 5.2, < 6.2) + responders (>= 2, < 4) iniparse (1.5.0) jbuilder (2.11.2) activesupport (>= 5.0.0) + jquery-rails (4.4.0) + rails-dom-testing (>= 1, < 3) + railties (>= 4.2.0) + thor (>= 0.14, < 2.0) + kaminari (1.2.1) + activesupport (>= 4.1.0) + kaminari-actionview (= 1.2.1) + kaminari-activerecord (= 1.2.1) + kaminari-core (= 1.2.1) + kaminari-actionview (1.2.1) + actionview + kaminari-core (= 1.2.1) + kaminari-activerecord (1.2.1) + activerecord + kaminari-core (= 1.2.1) + kaminari-core (1.2.1) listen (3.7.0) rb-fsevent (~> 0.10, >= 0.10.3) rb-inotify (~> 0.9, >= 0.9.10) @@ -240,10 +280,17 @@ GEM thor (~> 1.0) rainbow (3.0.0) rake (13.0.6) + ransack (2.4.2) + activerecord (>= 5.2.4) + activesupport (>= 5.2.4) + i18n rb-fsevent (0.11.0) rb-inotify (0.10.1) ffi (~> 1.0) regexp_parser (2.1.1) + responders (3.0.1) + actionpack (>= 5.0) + railties (>= 5.0) rexml (3.2.5) rubocop (1.21.0) parallel (~> 1.10) @@ -275,6 +322,7 @@ GEM rubocop (~> 1.0) rubocop-ast (>= 1.1.0) ruby-progressbar (1.11.0) + ruby2_keywords (0.0.5) rubyzip (2.3.2) sass (3.7.4) sass-listen (~> 4.0.0) @@ -337,9 +385,11 @@ PLATFORMS x86_64-linux DEPENDENCIES + activeadmin bootsnap (>= 1.4.4) byebug capybara + chartkick database_cleaner-active_record discard dotenv-rails diff --git a/app/admin/case_logs.rb b/app/admin/case_logs.rb new file mode 100644 index 000000000..cd303787e --- /dev/null +++ b/app/admin/case_logs.rb @@ -0,0 +1,20 @@ +ActiveAdmin.register CaseLog do + + # See permitted parameters documentation: + # https://github.com/activeadmin/activeadmin/blob/master/docs/2-resource-customization.md#setting-up-strong-parameters + permit_params do + permitted = [:status, :tenant_code, :person_1_age, :person_1_gender, :tenant_ethnic_group, :tenant_nationality, :previous_housing_situation, :armed_forces, :person_1_economic_status, :household_number_of_other_members, :person_2_relationship, :person_2_age, :person_2_gender, :person_2_economic_status, :person_3_relationship, :person_3_age, :person_3_gender, :person_3_economic_status, :person_4_relationship, :person_4_age, :person_4_gender, :person_4_economic_status, :person_5_relationship, :person_5_age, :person_5_gender, :person_5_economic_status, :person_6_relationship, :person_6_age, :person_6_gender, :person_6_economic_status, :person_7_relationship, :person_7_age, :person_7_gender, :person_7_economic_status, :person_8_relationship, :person_8_age, :person_8_gender, :person_8_economic_status, :homelessness, :reason_for_leaving_last_settled_home, :benefit_cap_spare_room_subsidy, :armed_forces_active, :armed_forces_injured, :armed_forces_partner, :medical_conditions, :pregnancy, :accessibility_requirements, :condition_effects, :tenancy_code, :tenancy_start_date, :starter_tenancy, :fixed_term_tenancy, :tenancy_type, :letting_type, :letting_provider, :property_location, :previous_postcode, :property_relet, :property_vacancy_reason, :property_reference, :property_unit_type, :property_building_type, :property_number_of_bedrooms, :property_void_date, :property_major_repairs, :property_major_repairs_date, :property_number_of_times_relet, :property_wheelchair_accessible, :net_income, :net_income_frequency, :net_income_uc_proportion, :housing_benefit, :rent_frequency, :basic_rent, :service_charge, :personal_service_charge, :support_charge, :total_charge, :outstanding_amount, :time_lived_in_la, :time_on_la_waiting_list, :previous_la, :property_postcode, :reasonable_preference, :reasonable_preference_reason, :cbl_letting, :chr_letting, :cap_letting, :outstanding_rent_or_charges, :other_reason_for_leaving_last_settled_home, :accessibility_requirements_fully_wheelchair_accessible_housing, :accessibility_requirements_wheelchair_access_to_essential_rooms, :accessibility_requirements_level_access_housing, :accessibility_requirements_other_disability_requirements, :accessibility_requirements_no_disability_requirements, :accessibility_requirements_do_not_know, :accessibility_requirements_prefer_not_to_say, :condition_effects_vision, :condition_effects_hearing, :condition_effects_mobility, :condition_effects_dexterity, :condition_effects_stamina, :condition_effects_learning, :condition_effects_memory, :condition_effects_mental_health, :condition_effects_social_or_behavioral, :condition_effects_other, :condition_effects_prefer_not_to_say, :reasonable_preference_reason_homeless, :reasonable_preference_reason_unsatisfactory_housing, :reasonable_preference_reason_medical_grounds, :reasonable_preference_reason_avoid_hardship, :reasonable_preference_reason_do_not_know, :other_tenancy_type, :override_net_income_validation, :net_income_known] + permitted + end + + index do + selectable_column + id_column + column :created_at + column :updated_at + column :status + column :tenant_code + column :property_postcode + actions + end +end diff --git a/app/admin/dashboard.rb b/app/admin/dashboard.rb new file mode 100644 index 000000000..4105f2304 --- /dev/null +++ b/app/admin/dashboard.rb @@ -0,0 +1,32 @@ +ActiveAdmin.register_page "Dashboard" do + menu priority: 1, label: proc { I18n.t("active_admin.dashboard") } + + content title: proc { I18n.t("active_admin.dashboard") } do + columns do + column do + panel "Recent Case Logs" do + table_for CaseLog.order(updated_at: :desc).limit(10) do + column :id + column :created_at + column :updated_at + column :status + column :tenant_code + column :property_postcode + end + end + end + + column do + panel "Total case logs in progress" do + para CaseLog.in_progress.size + end + panel "Total case logs completed" do + para CaseLog.completed.size + end + panel "Total case logs completed" do + pie_chart CaseLog.group(:status).size + end + end + end + end +end diff --git a/app/controllers/case_logs_controller.rb b/app/controllers/case_logs_controller.rb index 48fe5726d..f8bef385a 100644 --- a/app/controllers/case_logs_controller.rb +++ b/app/controllers/case_logs_controller.rb @@ -56,16 +56,14 @@ class CaseLogsController < ApplicationController def submit_form form = FormHandler.instance.get_form("2021_2022") @case_log = CaseLog.find(params[:id]) - previous_page = params[:case_log][:previous_page] - questions_for_page = form.questions_for_page(previous_page) - responses_for_page = question_responses(questions_for_page) - @case_log.previous_page = previous_page - if @case_log.update(responses_for_page) - redirect_path = get_next_page_path(form, previous_page, @case_log) + @case_log.page = params[:case_log][:page] + responses_for_page = responses_for_page(@case_log.page) + if @case_log.update(responses_for_page) && @case_log.has_no_unresolved_soft_errors? + redirect_path = get_next_page_path(form, @case_log.page, @case_log) redirect_to(send(redirect_path, @case_log)) else - page_info = form.all_pages[previous_page] - render "form/page", locals: { form: form, page_key: previous_page, page_info: page_info }, status: :unprocessable_entity + page_info = form.all_pages[@case_log.page] + render "form/page", locals: { form: form, page_key: @case_log.page, page_info: page_info }, status: :unprocessable_entity end end @@ -101,9 +99,12 @@ private API_ACTIONS = %w[create show update destroy].freeze - def question_responses(questions_for_page) - questions_for_page.each_with_object({}) do |(question_key, question_info), result| + def responses_for_page(page) + form = FormHandler.instance.get_form("2021_2022") + form.expected_responses_for_page(page).each_with_object({}) do |(question_key, question_info), result| question_params = params["case_log"][question_key] + next unless question_params + if question_info["type"] == "checkbox" question_info["answer_options"].keys.reject { |x| x.match(/divider/) }.each do |option| result[option] = question_params.include?(option) @@ -129,8 +130,8 @@ private params.require(:case_log).permit(CaseLog.editable_fields) end - def get_next_page_path(form, previous_page, case_log = {}) - content = form.all_pages[previous_page] + def get_next_page_path(form, page, case_log = {}) + content = form.all_pages[page] if content.key?("conditional_route_to") content["conditional_route_to"].each do |route, conditions| @@ -139,6 +140,6 @@ private end end end - form.next_page_redirect_path(previous_page) + form.next_page_redirect_path(page) end end diff --git a/app/javascript/packs/active_admin.js b/app/javascript/packs/active_admin.js new file mode 100644 index 000000000..286b2e630 --- /dev/null +++ b/app/javascript/packs/active_admin.js @@ -0,0 +1,7 @@ +// Load Active Admin's styles into Webpacker, +// see `active_admin.scss` for customization. +import "../stylesheets/active_admin"; + +import "@activeadmin/activeadmin"; + +import "chartkick/chart.js" diff --git a/app/javascript/packs/active_admin/print.scss b/app/javascript/packs/active_admin/print.scss new file mode 100644 index 000000000..79ac0361c --- /dev/null +++ b/app/javascript/packs/active_admin/print.scss @@ -0,0 +1,2 @@ +/* Active Admin Print Stylesheet */ +@import "~@activeadmin/activeadmin/src/scss/print"; diff --git a/app/javascript/stylesheets/active_admin.scss b/app/javascript/stylesheets/active_admin.scss new file mode 100644 index 000000000..762d851fb --- /dev/null +++ b/app/javascript/stylesheets/active_admin.scss @@ -0,0 +1,17 @@ +// Sass variable overrides must be declared before loading up Active Admin's styles. +// +// To view the variables that Active Admin provides, take a look at +// `app/assets/stylesheets/active_admin/mixins/_variables.scss` in the +// Active Admin source. +// +// For example, to change the sidebar width: +// $sidebar-width: 242px; + +// Active Admin's got SASS! +@import "~@activeadmin/activeadmin/src/scss/mixins"; +@import "~@activeadmin/activeadmin/src/scss/base"; + +// Overriding any non-variable Sass must be done after the fact. +// For example, to change the default status-tag color: +// +// .status_tag { background: #6090DB; } diff --git a/app/models/case_log.rb b/app/models/case_log.rb index 8d912354b..9c6660052 100644 --- a/app/models/case_log.rb +++ b/app/models/case_log.rb @@ -1,123 +1,20 @@ class CaseLogValidator < ActiveModel::Validator - # Methods to be used on save and continue need to be named 'validate_' - # followed by field name this is how the metaprogramming of the method - # name being call in the validate method works. - - def validate_person_1_age(record) - if record.person_1_age && !/^[1-9][0-9]?$|^120$/.match?(record.person_1_age.to_s) - record.errors.add :person_1_age, "Tenant age must be between 0 and 120" - end - end - - def validate_property_number_of_times_relet(record) - if record.property_number_of_times_relet && !/^[1-9]$|^0[1-9]$|^1[0-9]$|^20$/.match?(record.property_number_of_times_relet.to_s) - record.errors.add :property_number_of_times_relet, "Must be between 0 and 20" - end - end - - def validate_reasonable_preference(record) - if record.homelessness == "No" && record.reasonable_preference == "Yes" - record.errors.add :reasonable_preference, "Can not be Yes if Not Homeless immediately prior to this letting has been selected" - elsif record.reasonable_preference == "Yes" - if !record.reasonable_preference_reason_homeless && !record.reasonable_preference_reason_unsatisfactory_housing && !record.reasonable_preference_reason_medical_grounds && !record.reasonable_preference_reason_avoid_hardship && !record.reasonable_preference_reason_do_not_know - record.errors.add :reasonable_preference_reason, "If reasonable preference is Yes, a reason must be given" - end - elsif record.reasonable_preference == "No" - if record.reasonable_preference_reason_homeless || record.reasonable_preference_reason_unsatisfactory_housing || record.reasonable_preference_reason_medical_grounds || record.reasonable_preference_reason_avoid_hardship || record.reasonable_preference_reason_do_not_know - record.errors.add :reasonable_preference_reason, "If reasonable preference is No, no reasons should be given" - end - end - end - - def validate_other_reason_for_leaving_last_settled_home(record) - validate_other_field(record, "reason_for_leaving_last_settled_home", "other_reason_for_leaving_last_settled_home") - end - - def validate_reason_for_leaving_last_settled_home(record) - if record.reason_for_leaving_last_settled_home == "Do not know" && record.benefit_cap_spare_room_subsidy != "Do not know" - record.errors.add :benefit_cap_spare_room_subsidy, "must be do not know if tenant’s main reason for leaving is do not know" - end - end - - def validate_armed_forces_injured(record) - if (record.armed_forces == "Yes - a regular" || record.armed_forces == "Yes - a reserve") && record.armed_forces_injured.blank? - record.errors.add :armed_forces_injured, "You must answer the armed forces injury question if the tenant has served in the armed forces" - end - - if (record.armed_forces == "No" || record.armed_forces == "Prefer not to say") && record.armed_forces_injured.present? - record.errors.add :armed_forces_injured, "You must not answer the armed forces injury question if the tenant has not served in the armed forces or prefer not to say was chosen" - end - end - - def validate_outstanding_rent_amount(record) - if record.outstanding_rent_or_charges == "Yes" && record.outstanding_amount.blank? - record.errors.add :outstanding_amount, "You must answer the oustanding amout question if you have outstanding rent or charges." - end - if record.outstanding_rent_or_charges == "No" && record.outstanding_amount.present? - record.errors.add :outstanding_amount, "You must not answer the oustanding amout question if you don't have outstanding rent or charges." - end - end - - EMPLOYED_STATUSES = ["Full-time - 30 hours or more", "Part-time - Less than 30 hours"].freeze - def validate_net_income_uc_proportion(record) - (1..8).any? do |n| - economic_status = record["person_#{n}_economic_status"] - is_employed = EMPLOYED_STATUSES.include?(economic_status) - relationship = record["person_#{n}_relationship"] - is_partner_or_main = relationship == "Partner" || (relationship.nil? && economic_status.present?) - if is_employed && is_partner_or_main && record.net_income_uc_proportion == "All" - record.errors.add :net_income_uc_proportion, "income is from Universal Credit, state pensions or benefits cannot be All if the tenant or the partner works part or full time" - end - end - end - - def validate_armed_forces_active_response(record) - if record.armed_forces == "Yes - a regular" && record.armed_forces_active.blank? - record.errors.add :armed_forces_active, "You must answer the armed forces active question if the tenant has served as a regular in the armed forces" - end - - if record.armed_forces != "Yes - a regular" && record.armed_forces_active.present? - record.errors.add :armed_forces_active, "You must not answer the armed forces active question if the tenant has not served as a regular in the armed forces" - end - end - - def validate_household_pregnancy(record) - if (record.pregnancy == "Yes" || record.pregnancy == "Prefer not to say") && !women_of_child_bearing_age_in_household(record) - record.errors.add :pregnancy, "You must answer no as there are no female tenants aged 16-50 in the property" - end - end - - def validate_fixed_term_tenancy(record) - is_present = record.fixed_term_tenancy.present? - is_in_range = record.fixed_term_tenancy.to_i.between?(2, 99) - is_secure = record.tenancy_type == "Fixed term – Secure" - is_ast = record.tenancy_type == "Fixed term – Assured Shorthold Tenancy (AST)" - conditions = [ - { condition: !(is_secure || is_ast) && is_present, error: "You must only answer the fixed term tenancy length question if the tenancy type is fixed term" }, - { condition: is_ast && !is_in_range, error: "Fixed term – Assured Shorthold Tenancy (AST) should be between 2 and 99 years" }, - { condition: is_secure && (!is_in_range && is_present), error: "Fixed term – Secure should be between 2 and 99 years or not specified" }, - ] - - conditions.each { |condition| condition[:condition] ? (record.errors.add :fixed_term_tenancy, condition[:error]) : nil } - end - - def validate_other_tenancy_type(record) - validate_other_field(record, "tenancy_type", "other_tenancy_type") - end + # Validations methods need to be called 'validate_' to run on model save + # or 'validate_' to run on submit as well + include HouseholdValidations + include PropertyValidations + include FinancialValidations + include TenancyValidations def validate(record) # If we've come from the form UI we only want to validate the specific fields # that have just been submitted. If we're submitting a log via API or Bulk Upload # we want to validate all data fields. - question_to_validate = options[:previous_page] - if question_to_validate - if respond_to?("validate_#{question_to_validate}") - public_send("validate_#{question_to_validate}", record) - end + page_to_validate = options[:page] + if page_to_validate + public_send("validate_#{page_to_validate}", record) if respond_to?("validate_#{page_to_validate}") else - # This assumes that all methods in this class other than this one are - # validations to be run - validation_methods = public_methods(false) - [__callee__] + validation_methods = public_methods.select { |method| method.starts_with?("validate_") } validation_methods.each { |meth| public_send(meth, record) } end end @@ -135,37 +32,23 @@ private record.errors.add other_field.to_sym, "#{other_field_label} must not be provided if #{main_field_label} was not other" end end - - def women_of_child_bearing_age_in_household(record) - (1..8).any? do |n| - next if record["person_#{n}_gender"].nil? || record["person_#{n}_age"].nil? - - record["person_#{n}_gender"] == "Female" && record["person_#{n}_age"] >= 16 && record["person_#{n}_age"] <= 50 - end - end end class CaseLog < ApplicationRecord include Discard::Model + include SoftValidations default_scope -> { kept } - scope :not_started, -> { where(status: "not_started") } - scope :in_progress, -> { where(status: "in_progress") } scope :not_completed, -> { where.not(status: "completed") } - scope :completed, -> { where(status: "completed") } - validate :instance_validations + validates_with CaseLogValidator, ({ page: @page } || {}) before_save :update_status! - attr_writer :previous_page + attr_accessor :page enum status: { "not_started" => 0, "in_progress" => 1, "completed" => 2 } AUTOGENERATED_FIELDS = %w[id status created_at updated_at discarded_at].freeze - def instance_validations - validates_with CaseLogValidator, ({ previous_page: @previous_page } || {}) - end - def self.editable_fields attribute_names - AUTOGENERATED_FIELDS end @@ -182,6 +65,23 @@ class CaseLog < ApplicationRecord status == "in_progress" end + def weekly_net_income + case net_income_frequency + when "Weekly" + net_income + when "Monthly" + ((net_income * 12) / 52.0).round(0) + when "Yearly" + (net_income / 12.0).round(0) + end + end + + def applicable_income_range + return unless person_1_economic_status + + IncomeRange::ALLOWED[person_1_economic_status.to_sym] + end + private def update_status! @@ -219,10 +119,27 @@ private dynamically_not_required << "fixed_term_tenancy" end - if tenancy_type != "Other" + unless net_income_in_soft_max_range? || net_income_in_soft_min_range? + dynamically_not_required << "override_net_income_validation" + end + + unless tenancy_type == "Other" dynamically_not_required << "other_tenancy_type" end + unless net_income_known == "Yes" + dynamically_not_required << "net_income" + dynamically_not_required << "net_income_frequency" + end + + start_range = (household_number_of_other_members || 0) + 2 + (start_range..8).each do |n| + dynamically_not_required << "person_#{n}_age" + dynamically_not_required << "person_#{n}_gender" + dynamically_not_required << "person_#{n}_relationship" + dynamically_not_required << "person_#{n}_economic_status" + end + required.delete_if { |key, _value| dynamically_not_required.include?(key) } end end diff --git a/app/models/form.rb b/app/models/form.rb index fc11d2b36..ea803e5b6 100644 --- a/app/models/form.rb +++ b/app/models/form.rb @@ -41,6 +41,15 @@ class Form pages_for_subsection(subsection).map { |title, _value| questions_for_page(title) }.reduce(:merge) end + # Returns a hash with soft validation questions as keys + def soft_validations_for_page(page) + all_pages[page]["soft_validations"] + end + + def expected_responses_for_page(page) + questions_for_page(page).merge(soft_validations_for_page(page) || {}) + end + def first_page_for_subsection(subsection) pages_for_subsection(subsection).keys.first end diff --git a/app/models/income_range.rb b/app/models/income_range.rb new file mode 100644 index 000000000..2d0013d33 --- /dev/null +++ b/app/models/income_range.rb @@ -0,0 +1,15 @@ +class IncomeRange + ALLOWED = { + "Full-time - 30 hours or more": OpenStruct.new(soft_min: 143, soft_max: 730, hard_min: 90, hard_max: 1230), + "Part-time - Less than 30 hours": OpenStruct.new(soft_min: 67, soft_max: 620, hard_min: 50, hard_max: 950), + "In government training into work, such as New Deal": OpenStruct.new(soft_min: 80, soft_max: 480, hard_min: 40, hard_max: 990), + "Jobseeker": OpenStruct.new(soft_min: 50, soft_max: 370, hard_min: 10, hard_max: 450), + "Retired": OpenStruct.new(soft_min: 50, soft_max: 380, hard_min: 10, hard_max: 690), + "Not seeking work": OpenStruct.new(soft_min: 53, soft_max: 540, hard_min: 10, hard_max: 890), + "Full-time student": OpenStruct.new(soft_min: 47, soft_max: 460, hard_min: 10, hard_max: 1300), + "Unable to work because of long term sick or disability": OpenStruct.new(soft_min: 54, soft_max: 460, hard_min: 10, hard_max: 820), + "Child under 16": OpenStruct.new(soft_min: 50, soft_max: 450, hard_min: 10, hard_max: 750), + "Other": OpenStruct.new(soft_min: 50, soft_max: 580, hard_min: 10, hard_max: 1040), + "Prefer not to say": OpenStruct.new(soft_min: 47, soft_max: 730, hard_min: 10, hard_max: 1300), + }.freeze +end diff --git a/app/validations/financial_validations.rb b/app/validations/financial_validations.rb new file mode 100644 index 000000000..3567e334d --- /dev/null +++ b/app/validations/financial_validations.rb @@ -0,0 +1,37 @@ +module FinancialValidations + # Validations methods need to be called 'validate_' to run on model save + # or 'validate_' to run on submit as well + def validate_outstanding_rent_amount(record) + if record.outstanding_rent_or_charges == "Yes" && record.outstanding_amount.blank? + record.errors.add :outstanding_amount, "You must answer the oustanding amout question if you have outstanding rent or charges." + end + if record.outstanding_rent_or_charges == "No" && record.outstanding_amount.present? + record.errors.add :outstanding_amount, "You must not answer the oustanding amout question if you don't have outstanding rent or charges." + end + end + + EMPLOYED_STATUSES = ["Full-time - 30 hours or more", "Part-time - Less than 30 hours"].freeze + def validate_net_income_uc_proportion(record) + (1..8).any? do |n| + economic_status = record["person_#{n}_economic_status"] + is_employed = EMPLOYED_STATUSES.include?(economic_status) + relationship = record["person_#{n}_relationship"] + is_partner_or_main = relationship == "Partner" || (relationship.nil? && economic_status.present?) + if is_employed && is_partner_or_main && record.net_income_uc_proportion == "All" + record.errors.add :net_income_uc_proportion, "income is from Universal Credit, state pensions or benefits cannot be All if the tenant or the partner works part or full time" + end + end + end + + def validate_net_income(record) + return unless record.person_1_economic_status && record.weekly_net_income + + if record.weekly_net_income > record.applicable_income_range.hard_max + record.errors.add :net_income, "Net income cannot be greater than #{record.applicable_income_range.hard_max} given the tenant's working situation" + end + + if record.weekly_net_income < record.applicable_income_range.hard_min + record.errors.add :net_income, "Net income cannot be less than #{record.applicable_income_range.hard_min} given the tenant's working situation" + end + end +end diff --git a/app/validations/household_validations.rb b/app/validations/household_validations.rb new file mode 100644 index 000000000..0bfa142d5 --- /dev/null +++ b/app/validations/household_validations.rb @@ -0,0 +1,167 @@ +module HouseholdValidations + # Validations methods need to be called 'validate_' to run on model save + # or 'validate_' to run on submit as well + def validate_reasonable_preference(record) + if record.homelessness == "No" && record.reasonable_preference == "Yes" + record.errors.add :reasonable_preference, "Can not be Yes if Not Homeless immediately prior to this letting has been selected" + elsif record.reasonable_preference == "Yes" + if !record.reasonable_preference_reason_homeless && !record.reasonable_preference_reason_unsatisfactory_housing && !record.reasonable_preference_reason_medical_grounds && !record.reasonable_preference_reason_avoid_hardship && !record.reasonable_preference_reason_do_not_know + record.errors.add :reasonable_preference_reason, "If reasonable preference is Yes, a reason must be given" + end + elsif record.reasonable_preference == "No" + if record.reasonable_preference_reason_homeless || record.reasonable_preference_reason_unsatisfactory_housing || record.reasonable_preference_reason_medical_grounds || record.reasonable_preference_reason_avoid_hardship || record.reasonable_preference_reason_do_not_know + record.errors.add :reasonable_preference_reason, "If reasonable preference is No, no reasons should be given" + end + end + end + + def validate_other_reason_for_leaving_last_settled_home(record) + validate_other_field(record, "reason_for_leaving_last_settled_home", "other_reason_for_leaving_last_settled_home") + end + + def validate_reason_for_leaving_last_settled_home(record) + if record.reason_for_leaving_last_settled_home == "Do not know" && record.benefit_cap_spare_room_subsidy != "Do not know" + record.errors.add :benefit_cap_spare_room_subsidy, "must be do not know if tenant’s main reason for leaving is do not know" + end + end + + def validate_armed_forces_injured(record) + if (record.armed_forces == "Yes - a regular" || record.armed_forces == "Yes - a reserve") && record.armed_forces_injured.blank? + record.errors.add :armed_forces_injured, "You must answer the armed forces injury question if the tenant has served in the armed forces" + end + + if (record.armed_forces == "No" || record.armed_forces == "Prefer not to say") && record.armed_forces_injured.present? + record.errors.add :armed_forces_injured, "You must not answer the armed forces injury question if the tenant has not served in the armed forces or prefer not to say was chosen" + end + end + + def validate_armed_forces_active_response(record) + if record.armed_forces == "Yes - a regular" && record.armed_forces_active.blank? + record.errors.add :armed_forces_active, "You must answer the armed forces active question if the tenant has served as a regular in the armed forces" + end + + if record.armed_forces != "Yes - a regular" && record.armed_forces_active.present? + record.errors.add :armed_forces_active, "You must not answer the armed forces active question if the tenant has not served as a regular in the armed forces" + end + end + + def validate_household_pregnancy(record) + if (record.pregnancy == "Yes" || record.pregnancy == "Prefer not to say") && !women_of_child_bearing_age_in_household(record) + record.errors.add :pregnancy, "You must answer no as there are no female tenants aged 16-50 in the property" + end + end + + def validate_household_number_of_other_members(record) + (2..8).each do |n| + validate_person_age(record, n) + validate_person_age_matches_economic_status(record, n) + validate_person_age_matches_relationship(record, n) + validate_person_age_and_gender_match_economic_status(record, n) + validate_person_age_and_relationship_matches_economic_status(record, n) + end + validate_partner_count(record) + end + + def validate_person_1_age(record) + return unless record.person_1_age + + if !record.person_1_age.is_a?(Integer) || record.person_1_age < 16 || record.person_1_age > 120 + record.errors.add "person_1_age", "Tenant age must be an integer between 16 and 120" + end + end + + def validate_person_1_economic(record) + validate_person_age_matches_economic_status(record, 1) + end + + def validate_shared_housing_rooms(record) + unless record.property_unit_type.nil? + if record.property_unit_type == "Bed-sit" && record.property_number_of_bedrooms != 1 + record.errors.add :property_unit_type, "A bedsit can only have one bedroom" + end + + if !record.household_number_of_other_members.nil? && record.household_number_of_other_members.positive? && (record.property_unit_type.include?("Shared") && !record.property_number_of_bedrooms.to_i.between?(1, 7)) + record.errors.add :property_unit_type, "A shared house must have 1 to 7 bedrooms" + end + + if record.property_unit_type.include?("Shared") && !record.property_number_of_bedrooms.to_i.between?(1, 3) + record.errors.add :property_unit_type, "A shared house with less than two tenants must have 1 to 3 bedrooms" + end + end + end + +private + + def women_of_child_bearing_age_in_household(record) + (1..8).any? do |n| + next if record["person_#{n}_gender"].nil? || record["person_#{n}_age"].nil? + + record["person_#{n}_gender"] == "Female" && record["person_#{n}_age"] >= 16 && record["person_#{n}_age"] <= 50 + end + end + + def validate_person_age(record, person_num) + age = record.public_send("person_#{person_num}_age") + return unless age + + if !age.is_a?(Integer) || age < 1 || age > 120 + record.errors.add "person_#{person_num}_age".to_sym, "Tenant age must be an integer between 0 and 120" + end + end + + def validate_person_age_matches_economic_status(record, person_num) + age = record.public_send("person_#{person_num}_age") + economic_status = record.public_send("person_#{person_num}_economic_status") + return unless age && economic_status + + if age > 70 && economic_status != "Retired" + record.errors.add "person_#{person_num}_economic_status", "Tenant #{person_num} must be retired if over 70" + end + if age < 16 && economic_status != "Child under 16" + record.errors.add "person_#{person_num}_economic_status", "Tenant #{person_num} economic status must be Child under 16 if their age is under 16" + end + end + + def validate_person_age_matches_relationship(record, person_num) + age = record.public_send("person_#{person_num}_age") + relationship = record.public_send("person_#{person_num}_relationship") + return unless age && relationship + + if age < 16 && relationship != "Child - includes young adult and grown-up" + record.errors.add "person_#{person_num}_relationship", "Tenant #{person_num}'s relationship to tenant 1 must be Child if their age is under 16" + end + end + + def validate_person_age_and_relationship_matches_economic_status(record, person_num) + age = record.public_send("person_#{person_num}_age") + economic_status = record.public_send("person_#{person_num}_economic_status") + relationship = record.public_send("person_#{person_num}_relationship") + return unless age && economic_status && relationship + + if age >= 16 && age <= 19 && relationship == "Child - includes young adult and grown-up" && (economic_status != "Full-time student" || economic_status != "Prefer not to say") + record.errors.add "person_#{person_num}_economic_status", "If age is between 16 and 19 - tenant #{person_num} must be a full time student or prefer not to say." + end + end + + def validate_person_age_and_gender_match_economic_status(record, person_num) + age = record.public_send("person_#{person_num}_age") + gender = record.public_send("person_#{person_num}_gender") + economic_status = record.public_send("person_#{person_num}_economic_status") + return unless age && economic_status && gender + + if gender == "Male" && economic_status == "Retired" && age < 65 + record.errors.add "person_#{person_num}_age", "Male tenant who is retired must be 65 or over" + end + if gender == "Female" && economic_status == "Retired" && age < 60 + record.errors.add "person_#{person_num}_age", "Female tenant who is retired must be 60 or over" + end + end + + def validate_partner_count(record) + # TODO: probably need to keep track of which specific field is wrong so we can highlight it in the UI + partner_count = (2..8).count { |n| record.public_send("person_#{n}_relationship") == "Partner" } + if partner_count > 1 + record.errors.add :base, "Number of partners cannot be greater than 1" + end + end +end diff --git a/app/validations/property_validations.rb b/app/validations/property_validations.rb new file mode 100644 index 000000000..65dde4a08 --- /dev/null +++ b/app/validations/property_validations.rb @@ -0,0 +1,9 @@ +module PropertyValidations + # Validations methods need to be called 'validate_' to run on model save + # or 'validate_' to run on submit as well + def validate_property_number_of_times_relet(record) + if record.property_number_of_times_relet && !/^[1-9]$|^0[1-9]$|^1[0-9]$|^20$/.match?(record.property_number_of_times_relet.to_s) + record.errors.add :property_number_of_times_relet, "Property number of times relet must be between 0 and 20" + end + end +end diff --git a/app/validations/soft_validations.rb b/app/validations/soft_validations.rb new file mode 100644 index 000000000..b62490130 --- /dev/null +++ b/app/validations/soft_validations.rb @@ -0,0 +1,45 @@ +module SoftValidations + def has_no_unresolved_soft_errors? + soft_errors.empty? || soft_errors_overridden? + end + + def soft_errors + {}.merge(net_income_validations) + end + + def soft_errors_overridden? + public_send(soft_errors.keys.first) if soft_errors.present? + end + +private + + def net_income_validations + net_income_errors = {} + if net_income_in_soft_min_range? + net_income_errors["override_net_income_validation"] = OpenStruct.new( + message: "Net income is lower than expected based on the main tenant's working situation. Are you sure this is correct?", + hint_text: "This is based on the tenant's work situation: #{person_1_economic_status}", + ) + elsif net_income_in_soft_max_range? + net_income_errors["override_net_income_validation"] = OpenStruct.new( + message: "Net income is higher than expected based on the main tenant's working situation. Are you sure this is correct?", + hint_text: "This is based on the tenant's work situation: #{person_1_economic_status}", + ) + else + update_column(:override_net_income_validation, nil) + end + net_income_errors + end + + def net_income_in_soft_max_range? + return unless weekly_net_income && person_1_economic_status + + weekly_net_income.between?(applicable_income_range.soft_max, applicable_income_range.hard_max) + end + + def net_income_in_soft_min_range? + return unless weekly_net_income && person_1_economic_status + + weekly_net_income.between?(applicable_income_range.soft_min, applicable_income_range.hard_min) + end +end diff --git a/app/validations/tenancy_validations.rb b/app/validations/tenancy_validations.rb new file mode 100644 index 000000000..5738bc2a3 --- /dev/null +++ b/app/validations/tenancy_validations.rb @@ -0,0 +1,21 @@ +module TenancyValidations + # Validations methods need to be called 'validate_' to run on model save + # or 'validate_' to run on submit as well + def validate_fixed_term_tenancy(record) + is_present = record.fixed_term_tenancy.present? + is_in_range = record.fixed_term_tenancy.to_i.between?(2, 99) + is_secure = record.tenancy_type == "Fixed term – Secure" + is_ast = record.tenancy_type == "Fixed term – Assured Shorthold Tenancy (AST)" + conditions = [ + { condition: !(is_secure || is_ast) && is_present, error: "You must only answer the fixed term tenancy length question if the tenancy type is fixed term" }, + { condition: is_ast && !is_in_range, error: "Fixed term – Assured Shorthold Tenancy (AST) should be between 2 and 99 years" }, + { condition: is_secure && (!is_in_range && is_present), error: "Fixed term – Secure should be between 2 and 99 years or not specified" }, + ] + + conditions.each { |condition| condition[:condition] ? (record.errors.add :fixed_term_tenancy, condition[:error]) : nil } + end + + def validate_other_tenancy_type(record) + validate_other_field(record, "tenancy_type", "other_tenancy_type") + end +end diff --git a/app/views/form/_validation_override_question.html.erb b/app/views/form/_validation_override_question.html.erb new file mode 100644 index 000000000..947791e75 --- /dev/null +++ b/app/views/form/_validation_override_question.html.erb @@ -0,0 +1,11 @@ +
+ <%= f.govuk_check_boxes_fieldset @case_log.soft_errors.keys.first, + legend: { text: @case_log.soft_errors.values.first.message, size: "l" }, + hint: { text: @case_log.soft_errors.values.first.hint_text } do %> + + <%= f.govuk_check_box @case_log.soft_errors.keys.first, @case_log.soft_errors.keys.first, + label: { text: "Yes" }, + checked: f.object.send(@case_log.soft_errors.keys.first) + %> + <% end %> +
diff --git a/app/views/form/page.html.erb b/app/views/form/page.html.erb index 878fefd66..dca0b7672 100644 --- a/app/views/form/page.html.erb +++ b/app/views/form/page.html.erb @@ -1,5 +1,7 @@ + + <% content_for :before_content do %> - <%= link_to 'Back', 'javascript:history.back()', class: "govuk-back-link" %> + <%= link_to 'Back', :back, class: "govuk-back-link" %> <% end %> <%= turbo_frame_tag "case_log_form", target: "_top" do %> @@ -17,7 +19,11 @@ <% end %> - <%= f.hidden_field :previous_page, value: page_key %> + <% if @case_log.soft_errors.present? && @case_log.soft_errors.keys.first == page_info["soft_validations"]&.keys&.first %> + <%= render partial: "form/validation_override_question", locals: { f: f } %> + <% end %> + + <%= f.hidden_field :page, value: page_key %> <%= f.govuk_submit "Save and continue" %> <% end %> <% end %> diff --git a/config/application.rb b/config/application.rb index 45edf3531..62eda1120 100644 --- a/config/application.rb +++ b/config/application.rb @@ -29,7 +29,7 @@ module DataCollector # These settings can be overridden in specific environments using the files # in config/environments, which are processed later. # - # config.time_zone = "Central Time (US & Canada)" + config.time_zone = "London" # config.eager_load_paths << Rails.root.join("extras") # Don't generate system test files. diff --git a/config/forms/2021_2022.json b/config/forms/2021_2022.json index 8355b4f93..406f5a69f 100644 --- a/config/forms/2021_2022.json +++ b/config/forms/2021_2022.json @@ -1633,6 +1633,21 @@ "header": "", "description": "", "questions": { + "net_income_known": { + "check_answer_label": "Income known", + "header": "Do you know the tenant and their partner's net income?", + "hint_text": "", + "type": "radio", + "answer_options": { + "0": "Yes", + "1": "No", + "2": "Tenant prefers not to say" + }, + "conditional_for": { + "net_income": ["Yes"], + "net_income_frequency": ["Yes"] + } + }, "net_income": { "check_answer_label": "Income", "header": "What is the tenant’s /and partner’s combined income after tax?", @@ -1652,6 +1667,15 @@ "2": "Yearly" } } + }, + "soft_validations": { + "override_net_income_validation": { + "check_answer_label": "Net income confirmed?", + "type": "validation_override", + "answer_options": { + "override_net_income_validation": "Yes" + } + } } }, "net_income_uc_proportion": { diff --git a/config/initializers/active_admin.rb b/config/initializers/active_admin.rb new file mode 100644 index 000000000..4ef658194 --- /dev/null +++ b/config/initializers/active_admin.rb @@ -0,0 +1,335 @@ +ActiveAdmin.setup do |config| + # == Site Title + # + # Set the title that is displayed on the main layout + # for each of the active admin pages. + # + config.site_title = "DLUHC CORE" + + # Set the link url for the title. For example, to take + # users to your main site. Defaults to no link. + # + # config.site_title_link = "/" + + # Set an optional image to be displayed for the header + # instead of a string (overrides :site_title) + # + # Note: Aim for an image that's 21px high so it fits in the header. + # + # config.site_title_image = "logo.png" + + # == Default Namespace + # + # Set the default namespace each administration resource + # will be added to. + # + # eg: + # config.default_namespace = :hello_world + # + # This will create resources in the HelloWorld module and + # will namespace routes to /hello_world/* + # + # To set no namespace by default, use: + # config.default_namespace = false + # + # Default: + # config.default_namespace = :admin + # + # You can customize the settings for each namespace by using + # a namespace block. For example, to change the site title + # within a namespace: + # + # config.namespace :admin do |admin| + # admin.site_title = "Custom Admin Title" + # end + # + # This will ONLY change the title for the admin section. Other + # namespaces will continue to use the main "site_title" configuration. + + # == User Authentication + # + # Active Admin will automatically call an authentication + # method in a before filter of all controller actions to + # ensure that there is a currently logged in admin user. + # + # This setting changes the method which Active Admin calls + # within the application controller. + # config.authentication_method = :authenticate_admin_user! + + # == User Authorization + # + # Active Admin will automatically call an authorization + # method in a before filter of all controller actions to + # ensure that there is a user with proper rights. You can use + # CanCanAdapter or make your own. Please refer to documentation. + # config.authorization_adapter = ActiveAdmin::CanCanAdapter + + # In case you prefer Pundit over other solutions you can here pass + # the name of default policy class. This policy will be used in every + # case when Pundit is unable to find suitable policy. + # config.pundit_default_policy = "MyDefaultPunditPolicy" + + # If you wish to maintain a separate set of Pundit policies for admin + # resources, you may set a namespace here that Pundit will search + # within when looking for a resource's policy. + # config.pundit_policy_namespace = :admin + + # You can customize your CanCan Ability class name here. + # config.cancan_ability_class = "Ability" + + # You can specify a method to be called on unauthorized access. + # This is necessary in order to prevent a redirect loop which happens + # because, by default, user gets redirected to Dashboard. If user + # doesn't have access to Dashboard, he'll end up in a redirect loop. + # Method provided here should be defined in application_controller.rb. + # config.on_unauthorized_access = :access_denied + + # == Current User + # + # Active Admin will associate actions with the current + # user performing them. + # + # This setting changes the method which Active Admin calls + # (within the application controller) to return the currently logged in user. + # config.current_user_method = :current_admin_user + + # == Logging Out + # + # Active Admin displays a logout link on each screen. These + # settings configure the location and method used for the link. + # + # This setting changes the path where the link points to. If it's + # a string, the strings is used as the path. If it's a Symbol, we + # will call the method to return the path. + # + # Default: + config.logout_link_path = :destroy_admin_user_session_path + + # This setting changes the http method used when rendering the + # link. For example :get, :delete, :put, etc.. + # + # Default: + # config.logout_link_method = :get + + # == Root + # + # Set the action to call for the root path. You can set different + # roots for each namespace. + # + # Default: + # config.root_to = 'dashboard#index' + + # == Admin Comments + # + # This allows your users to comment on any resource registered with Active Admin. + # + # You can completely disable comments: + config.comments = false + # + # You can change the name under which comments are registered: + # config.comments_registration_name = 'AdminComment' + # + # You can change the order for the comments and you can change the column + # to be used for ordering: + # config.comments_order = 'created_at ASC' + # + # You can disable the menu item for the comments index page: + # config.comments_menu = false + # + # You can customize the comment menu: + # config.comments_menu = { parent: 'Admin', priority: 1 } + + # == Batch Actions + # + # Enable and disable Batch Actions + # + config.batch_actions = true + + # == Controller Filters + # + # You can add before, after and around filters to all of your + # Active Admin resources and pages from here. + # + # config.before_action :do_something_awesome + + # == Attribute Filters + # + # You can exclude possibly sensitive model attributes from being displayed, + # added to forms, or exported by default by ActiveAdmin + # + config.filter_attributes = [:encrypted_password, :password, :password_confirmation] + + # == Localize Date/Time Format + # + # Set the localize format to display dates and times. + # To understand how to localize your app with I18n, read more at + # https://guides.rubyonrails.org/i18n.html + # + # You can run `bin/rails runner 'puts I18n.t("date.formats")'` to see the + # available formats in your application. + # + config.localize_format = :long + + # == Setting a Favicon + # + # config.favicon = 'favicon.ico' + + # == Meta Tags + # + # Add additional meta tags to the head element of active admin pages. + # + # Add tags to all pages logged in users see: + # config.meta_tags = { author: 'My Company' } + + # By default, sign up/sign in/recover password pages are excluded + # from showing up in search engine results by adding a robots meta + # tag. You can reset the hash of meta tags included in logged out + # pages: + # config.meta_tags_for_logged_out_pages = {} + + # == Removing Breadcrumbs + # + # Breadcrumbs are enabled by default. You can customize them for individual + # resources or you can disable them globally from here. + # + # config.breadcrumb = false + + # == Create Another Checkbox + # + # Create another checkbox is disabled by default. You can customize it for individual + # resources or you can enable them globally from here. + # + # config.create_another = true + + # == Register Stylesheets & Javascripts + # + # We recommend using the built in Active Admin layout and loading + # up your own stylesheets / javascripts to customize the look + # and feel. + # + # To load a stylesheet: + # config.register_stylesheet 'my_stylesheet.css' + # + # You can provide an options hash for more control, which is passed along to stylesheet_link_tag(): + # config.register_stylesheet 'my_print_stylesheet.css', media: :print + # + # To load a javascript file: + # config.register_javascript 'my_javascript.js' + + # == CSV options + # + # Set the CSV builder separator + # config.csv_options = { col_sep: ';' } + # + # Force the use of quotes + # config.csv_options = { force_quotes: true } + + # == Menu System + # + # You can add a navigation menu to be used in your application, or configure a provided menu + # + # To change the default utility navigation to show a link to your website & a logout btn + # + # config.namespace :admin do |admin| + # admin.build_menu :utility_navigation do |menu| + # menu.add label: "My Great Website", url: "http://www.mygreatwebsite.com", html_options: { target: :blank } + # admin.add_logout_button_to_menu menu + # end + # end + # + # If you wanted to add a static menu item to the default menu provided: + # + # config.namespace :admin do |admin| + # admin.build_menu :default do |menu| + # menu.add label: "My Great Website", url: "http://www.mygreatwebsite.com", html_options: { target: :blank } + # end + # end + + # == Download Links + # + # You can disable download links on resource listing pages, + # or customize the formats shown per namespace/globally + # + # To disable/customize for the :admin namespace: + # + # config.namespace :admin do |admin| + # + # # Disable the links entirely + # admin.download_links = false + # + # # Only show XML & PDF options + # admin.download_links = [:xml, :pdf] + # + # # Enable/disable the links based on block + # # (for example, with cancan) + # admin.download_links = proc { can?(:view_download_links) } + # + # end + + # == Pagination + # + # Pagination is enabled by default for all resources. + # You can control the default per page count for all resources here. + # + # config.default_per_page = 30 + # + # You can control the max per page count too. + # + # config.max_per_page = 10_000 + + # == Filters + # + # By default the index screen includes a "Filters" sidebar on the right + # hand side with a filter for each attribute of the registered model. + # You can enable or disable them for all resources here. + # + # config.filters = true + # + # By default the filters include associations in a select, which means + # that every record will be loaded for each association (up + # to the value of config.maximum_association_filter_arity). + # You can enabled or disable the inclusion + # of those filters by default here. + # + # config.include_default_association_filters = true + + # config.maximum_association_filter_arity = 256 # default value of :unlimited will change to 256 in a future version + # config.filter_columns_for_large_association = [ + # :display_name, + # :full_name, + # :name, + # :username, + # :login, + # :title, + # :email, + # ] + # config.filter_method_for_large_association = '_starts_with' + + # == Head + # + # You can add your own content to the site head like analytics. Make sure + # you only pass content you trust. + # + # config.head = ''.html_safe + + # == Footer + # + # By default, the footer shows the current Active Admin version. You can + # override the content of the footer here. + # + # config.footer = 'my custom footer text' + + # == Sorting + # + # By default ActiveAdmin::OrderClause is used for sorting logic + # You can inherit it with own class and inject it for all resources + # + # config.order_clause = MyOrderClause + + # == Webpacker + # + # By default, Active Admin uses Sprocket's asset pipeline. + # You can switch to using Webpacker here. + # + config.use_webpacker = true +end diff --git a/config/routes.rb b/config/routes.rb index 5a95075fd..63163937b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,7 +1,8 @@ Rails.application.routes.draw do # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html + ActiveAdmin.routes(self) + root to: "test#index" get "about", to: "about#index" - get "/", to: "test#index" post "/case_logs/:id", to: "case_logs#submit_form" diff --git a/config/webpack/environment.js b/config/webpack/environment.js index d16d9af74..34ab86b05 100644 --- a/config/webpack/environment.js +++ b/config/webpack/environment.js @@ -1,3 +1,5 @@ const { environment } = require('@rails/webpacker') +const jquery = require('./plugins/jquery') +environment.plugins.prepend('jquery', jquery) module.exports = environment diff --git a/config/webpack/plugins/jquery.js b/config/webpack/plugins/jquery.js new file mode 100644 index 000000000..54c61ec83 --- /dev/null +++ b/config/webpack/plugins/jquery.js @@ -0,0 +1,7 @@ +const webpack = require('webpack') + +module.exports = new webpack.ProvidePlugin({ + "$":"jquery", + "jQuery":"jquery", + "window.jQuery":"jquery" +}); diff --git a/db/migrate/20211028083105_change_net_income_type.rb b/db/migrate/20211028083105_change_net_income_type.rb new file mode 100644 index 000000000..85fedcceb --- /dev/null +++ b/db/migrate/20211028083105_change_net_income_type.rb @@ -0,0 +1,9 @@ +class ChangeNetIncomeType < ActiveRecord::Migration[6.1] + def up + change_column :case_logs, :net_income, "integer USING CAST(property_number_of_times_relet AS integer)" + end + + def down + change_column :case_logs, :net_income, :string + end +end diff --git a/db/migrate/20211028090049_add_net_income_override.rb b/db/migrate/20211028090049_add_net_income_override.rb new file mode 100644 index 000000000..f776ada87 --- /dev/null +++ b/db/migrate/20211028090049_add_net_income_override.rb @@ -0,0 +1,5 @@ +class AddNetIncomeOverride < ActiveRecord::Migration[6.1] + def change + add_column :case_logs, :override_net_income_validation, :boolean + end +end diff --git a/db/migrate/20211028095000_add_net_income_known_field.rb b/db/migrate/20211028095000_add_net_income_known_field.rb new file mode 100644 index 000000000..89e82c29f --- /dev/null +++ b/db/migrate/20211028095000_add_net_income_known_field.rb @@ -0,0 +1,7 @@ +class AddNetIncomeKnownField < ActiveRecord::Migration[6.1] + def change + change_table :case_logs, bulk: true do |t| + t.column :net_income_known, :string + end + end +end diff --git a/db/schema.rb b/db/schema.rb index c29297c23..ddd456c43 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -86,7 +86,7 @@ ActiveRecord::Schema.define(version: 2021_11_02_100820) do t.string "property_major_repairs_date" t.integer "property_number_of_times_relet" t.string "property_wheelchair_accessible" - t.string "net_income" + t.integer "net_income" t.string "net_income_frequency" t.string "net_income_uc_proportion" t.string "housing_benefit" @@ -144,6 +144,8 @@ ActiveRecord::Schema.define(version: 2021_11_02_100820) do t.string "needs_type" t.string "sale_completion_date" t.string "purchaser_code" + t.boolean "override_net_income_validation" + t.string "net_income_known" t.index ["discarded_at"], name: "index_case_logs_on_discarded_at" end diff --git a/docs/api/DLUHC-CORE-Data.v1.json b/docs/api/DLUHC-CORE-Data.v1.json index 7a4d495e2..e4e08d15f 100644 --- a/docs/api/DLUHC-CORE-Data.v1.json +++ b/docs/api/DLUHC-CORE-Data.v1.json @@ -110,7 +110,7 @@ "value": { "errors": { "person_1_age": [ - "Tenant age must be between 0 and 120" + "Tenant age must be an integer between 16 and 120" ] } } @@ -220,7 +220,7 @@ "If reasonable preference is Yes, a reason must be given" ], "person_1_age": [ - "Tenant age must be between 0 and 120" + "Tenant age must be an integer between 16 and 120" ] } } @@ -937,6 +937,15 @@ "property_wheelchair_accessible": { "type": "boolean" }, + "net_income_known": { + "type": "string", + "minLength": 1, + "enum": [ + "Yes", + "No", + "Tenant prefers not to say" + ] + }, "net_income": { "type": "number" }, @@ -1208,7 +1217,8 @@ "reasonable_preference_reason_medical_grounds", "reasonable_preference_reason_avoid_hardship", "reasonable_preference_reason_do_not_know", - "other_tenancy-type" + "other_tenancy-type", + "net_income_known" ] } }, @@ -1225,4 +1235,4 @@ } } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index c7f92f096..5da73dd8c 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "data-collector", "private": true, "dependencies": { + "@activeadmin/activeadmin": "^2.9.0", "@hotwired/stimulus": "^3.0.0", "@hotwired/stimulus-webpack-helpers": "^1.0.1", "@hotwired/turbo": "^7.0.0-rc.3", @@ -10,6 +11,8 @@ "@rails/activestorage": "^6.0.0", "@rails/ujs": "^6.0.0", "@rails/webpacker": "5.4.0", + "chart.js": "^3.6.0", + "chartkick": "^4.1.0", "govuk-frontend": "^3.13.0", "stimulus": "^3.0.0", "webpack": "^4.46.0", diff --git a/spec/controllers/case_logs_controller_spec.rb b/spec/controllers/case_logs_controller_spec.rb index 29b129e5e..3adcf8335 100644 --- a/spec/controllers/case_logs_controller_spec.rb +++ b/spec/controllers/case_logs_controller_spec.rb @@ -52,13 +52,13 @@ RSpec.describe CaseLogsController, type: :controller do %w[ accessibility_requirements_fully_wheelchair_accessible_housing accessibility_requirements_wheelchair_access_to_essential_rooms accessibility_requirements_level_access_housing], - previous_page: "accessibility_requirements" } + page: "accessibility_requirements" } end let(:new_case_log_form_params) do { accessibility_requirements: %w[accessibility_requirements_level_access_housing], - previous_page: "accessibility_requirements", + page: "accessibility_requirements", } end @@ -88,7 +88,7 @@ RSpec.describe CaseLogsController, type: :controller do accessibility_requirements_wheelchair_access_to_essential_rooms accessibility_requirements_level_access_housing], tenant_code: tenant_code, - previous_page: "accessibility_requirements" } + page: "accessibility_requirements" } end let(:questions_for_page) do { "accessibility_requirements" => @@ -124,17 +124,21 @@ RSpec.describe CaseLogsController, type: :controller do end context "conditional routing" do + before do + allow_any_instance_of(CaseLogValidator).to receive(:validate_household_pregnancy).and_return(true) + end + let(:case_log_form_conditional_question_yes_params) do { pregnancy: "Yes", - previous_page: "conditional_question", + page: "conditional_question", } end let(:case_log_form_conditional_question_no_params) do { pregnancy: "No", - previous_page: "conditional_question", + page: "conditional_question", } end diff --git a/spec/factories/case_log.rb b/spec/factories/case_log.rb index 6e5960329..bed593844 100644 --- a/spec/factories/case_log.rb +++ b/spec/factories/case_log.rb @@ -6,7 +6,7 @@ FactoryBot.define do tenant_code { "TH356" } property_postcode { "SW2 6HI" } previous_postcode { "P0 5ST" } - person_1_age { "12" } + person_1_age { "18" } end trait :completed do status { 2 } diff --git a/spec/features/case_log_spec.rb b/spec/features/case_log_spec.rb index 587fc80f1..f46a64a3e 100644 --- a/spec/features/case_log_spec.rb +++ b/spec/features/case_log_spec.rb @@ -32,10 +32,6 @@ RSpec.describe "Test Features" do describe "Create new log" do it "redirects to the task list for the new log" do visit("/case_logs") - # Ensure that we've finished creating both case logs before running the - # Capybara click part to ensure we don't get creation race conditions - expect(page).to have_link(nil, href: "/case_logs/#{case_log.id}") - expect(page).to have_link(nil, href: "/case_logs/#{empty_case_log.id}") click_link("Create new log") id = CaseLog.order(created_at: :desc).first.id expect(page).to have_content("Tasklist for log #{id}") @@ -184,7 +180,7 @@ RSpec.describe "Test Features" do it "displays text answers in inputs if they are already saved" do visit("/case_logs/#{id}/person_1_age") - expect(page).to have_field("case-log-person-1-age-field", with: "12") + expect(page).to have_field("case-log-person-1-age-field", with: "18") end it "displays checkbox answers in inputs if they are already saved" do @@ -233,6 +229,15 @@ RSpec.describe "Test Features" do expect(page).to have_current_path("/case_logs/#{id}/#{pages[index + 1]}") end end + + context "when changing an answer from the check answers page" do + it "the back button routes correctly" do + visit("/case_logs/#{id}/household_characteristics/check_answers") + first("a", text: /Answer/).click + click_link("Back") + expect(page).to have_current_path("/case_logs/#{id}/household_characteristics/check_answers") + end + end end end @@ -377,11 +382,66 @@ RSpec.describe "Test Features" do end end + describe "Soft Validation" do + context "given a weekly net income that is above the expected amount for the given economic status but below the hard max" do + let!(:case_log) { FactoryBot.create(:case_log, :in_progress, person_1_economic_status: "Full-time - 30 hours or more") } + let(:income_over_soft_limit) { 750 } + let(:income_under_soft_limit) { 700 } + + it "prompts the user to confirm the value is correct" do + visit("/case_logs/#{case_log.id}/net_income") + fill_in("case-log-net-income-field", with: income_over_soft_limit) + choose("case-log-net-income-frequency-weekly-field", allow_label_click: true) + click_button("Save and continue") + expect(page).to have_content("Are you sure this is correct?") + check("case-log-override-net-income-validation-override-net-income-validation-field", allow_label_click: true) + click_button("Save and continue") + expect(page).to have_current_path("/case_logs/#{case_log.id}/net_income_uc_proportion") + end + + it "does not require confirming the value if the value is amended" do + visit("/case_logs/#{case_log.id}/net_income") + fill_in("case-log-net-income-field", with: income_over_soft_limit) + choose("case-log-net-income-frequency-weekly-field", allow_label_click: true) + click_button("Save and continue") + fill_in("case-log-net-income-field", with: income_under_soft_limit) + click_button("Save and continue") + expect(page).to have_current_path("/case_logs/#{case_log.id}/net_income_uc_proportion") + case_log.reload + expect(case_log.override_net_income_validation).to be_nil + end + + it "clears the confirmation question if the amount was amended and the page is returned to using the back button", js: true do + visit("/case_logs/#{case_log.id}/net_income") + fill_in("case-log-net-income-field", with: income_over_soft_limit) + choose("case-log-net-income-frequency-weekly-field", allow_label_click: true) + click_button("Save and continue") + fill_in("case-log-net-income-field", with: income_under_soft_limit) + click_button("Save and continue") + click_link(text: "Back") + expect(page).not_to have_content("Are you sure this is correct?") + end + + it "does not clear the confirmation question if the page is returned to using the back button and the amount is still over the soft limit", js: true do + visit("/case_logs/#{case_log.id}/net_income") + fill_in("case-log-net-income-field", with: income_over_soft_limit) + choose("case-log-net-income-frequency-weekly-field", allow_label_click: true) + click_button("Save and continue") + check("case-log-override-net-income-validation-override-net-income-validation-field", allow_label_click: true) + click_button("Save and continue") + click_link(text: "Back") + expect(page).to have_content("Are you sure this is correct?") + end + end + end + describe "conditional page routing", js: true do + before do + allow_any_instance_of(CaseLogValidator).to receive(:validate_household_pregnancy).and_return(true) + end + it "can route the user to a different page based on their answer on the current page" do visit("case_logs/#{id}/conditional_question") - # using a question name that is already in the db to avoid - # having to add a new column to the db for this test choose("case-log-pregnancy-yes-field", allow_label_click: true) click_button("Save and continue") expect(page).to have_current_path("/case_logs/#{id}/conditional_question_yes_page") diff --git a/spec/fixtures/complete_case_log.json b/spec/fixtures/complete_case_log.json index 3647c6163..9c84a7993 100644 --- a/spec/fixtures/complete_case_log.json +++ b/spec/fixtures/complete_case_log.json @@ -68,6 +68,7 @@ "property_major_repairs_date": "05/05/2020", "property_number_of_times_relet": 2, "property_wheelchair_accessible": true, + "net_income_known": "Yes", "net_income": 0, "net_income_frequency": null, "net_income_uc_proportion": "Some", diff --git a/spec/fixtures/forms/test_form.json b/spec/fixtures/forms/test_form.json index 35590cb3e..2bcd34aaa 100644 --- a/spec/fixtures/forms/test_form.json +++ b/spec/fixtures/forms/test_form.json @@ -328,6 +328,15 @@ "2": "Yearly" } } + }, + "soft_validations": { + "override_net_income_validation": { + "check_answer_label": "Net income confirmed?", + "type": "validation_override", + "answer_options": { + "override_net_income_validation": "Yes" + } + } } }, "net_income_uc_proportion": { diff --git a/spec/models/case_log_spec.rb b/spec/models/case_log_spec.rb index 8ac7d349d..684488a96 100644 --- a/spec/models/case_log_spec.rb +++ b/spec/models/case_log_spec.rb @@ -97,6 +97,45 @@ RSpec.describe Form, type: :model do end end + context "Shared accomodation bedrooms validation" do + it "you must have more than zero bedrooms" do + expect { + CaseLog.create!(property_unit_type: "Shared house", + property_number_of_bedrooms: 0) + }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "you must answer less than 8 bedrooms" do + expect { + CaseLog.create!(property_unit_type: "Shared bungalow", + property_number_of_bedrooms: 8, + household_number_of_other_members: 1) + }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "you must answer less than 8 bedrooms" do + expect { + CaseLog.create!(property_unit_type: "Shared bungalow", + property_number_of_bedrooms: 4, + household_number_of_other_members: 0) + }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "A bedsit must only have one room" do + expect { + CaseLog.create!(property_unit_type: "Bed-sit", + property_number_of_bedrooms: 2) + }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "A bedsit must only have one room" do + expect { + CaseLog.create!(property_unit_type: "Bed-sit", + property_number_of_bedrooms: 0) + }.to raise_error(ActiveRecord::RecordInvalid) + end + end + context "outstanding rent or charges validation" do it "must be anwered if answered yes to outstanding rent or charges" do expect { @@ -206,6 +245,40 @@ RSpec.describe Form, type: :model do end end + context "household_member_validations" do + it "validate that persons aged under 16 must have relationship Child" do + expect { CaseLog.create!(person_2_age: 14, person_2_relationship: "Partner") }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "validate that persons aged over 70 must be retired" do + expect { CaseLog.create!(person_2_age: 71, person_2_economic_status: "Full-time - 30 hours or more") }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "validate that a male, retired persons must be over 65" do + expect { CaseLog.create!(person_2_age: 64, person_2_gender: "Male", person_2_economic_status: "Retired") }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "validate that a female, retired persons must be over 60" do + expect { CaseLog.create!(person_2_age: 59, person_2_gender: "Female", person_2_economic_status: "Retired") }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "validate that persons aged under 16 must be a child (economically speaking)" do + expect { CaseLog.create!(person_2_age: 15, person_2_economic_status: "Full-time - 30 hours or more") }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "validate that persons aged between 16 and 19 that are a child must be a full time student or economic status refused" do + expect { CaseLog.create!(person_2_age: 17, person_2_relationship: "Child - includes young adult and grown-up", person_2_economic_status: "Full-time - 30 hours or more") }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "validate that persons aged under 16 must be a child relationship" do + expect { CaseLog.create!(person_2_age: 15, person_2_relationship: "Partner") }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "validate that no more than 1 partner relationship exists" do + expect { CaseLog.create!(person_2_relationship: "Partner", person_3_relationship: "Partner") }.to raise_error(ActiveRecord::RecordInvalid) + end + end + context "other tenancy type validation" do it "must be provided if tenancy type was given as other" do expect { @@ -231,6 +304,28 @@ RSpec.describe Form, type: :model do }.not_to raise_error end end + + context "income ranges" do + it "validates net income maximum" do + expect { + CaseLog.create!( + person_1_economic_status: "Full-time - 30 hours or more", + net_income: 5000, + net_income_frequency: "Weekly", + ) + }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "validates net income minimum" do + expect { + CaseLog.create!( + person_1_economic_status: "Full-time - 30 hours or more", + net_income: 1, + net_income_frequency: "Weekly", + ) + }.to raise_error(ActiveRecord::RecordInvalid) + end + end end describe "status" do @@ -249,4 +344,24 @@ RSpec.describe Form, type: :model do expect(in_progress_case_log.completed?).to be(false) end end + + describe "weekly_net_income" do + let(:net_income) { 5000 } + let(:case_log) { FactoryBot.build(:case_log, net_income: net_income) } + + it "returns input income if frequency is already weekly" do + case_log.net_income_frequency = "Weekly" + expect(case_log.weekly_net_income).to eq(net_income) + end + + it "calculates the correct weekly income from monthly income" do + case_log.net_income_frequency = "Monthly" + expect(case_log.weekly_net_income).to eq(1154) + end + + it "calculates the correct weekly income from yearly income" do + case_log.net_income_frequency = "Yearly" + expect(case_log.weekly_net_income).to eq(417) + end + end end diff --git a/spec/requests/case_log_controller_spec.rb b/spec/requests/case_log_controller_spec.rb index e6c866c90..d3f67683b 100644 --- a/spec/requests/case_log_controller_spec.rb +++ b/spec/requests/case_log_controller_spec.rb @@ -66,7 +66,7 @@ RSpec.describe CaseLogsController, type: :request do it "validates case log parameters" do json_response = JSON.parse(response.body) expect(response).to have_http_status(:unprocessable_entity) - expect(json_response["errors"]).to match_array([["property_number_of_times_relet", ["Must be between 0 and 20"]], ["person_1_age", ["Tenant age must be between 0 and 120"]]]) + expect(json_response["errors"]).to match_array([["property_number_of_times_relet", ["Property number of times relet must be between 0 and 20"]], ["person_1_age", ["Tenant age must be an integer between 16 and 120"]]]) end end @@ -115,6 +115,14 @@ RSpec.describe CaseLogsController, type: :request do json_response = JSON.parse(response.body) expect(json_response["status"]).to eq(case_log.status) end + + context "invalid case log id" do + let(:id) { (CaseLog.order(:id).last&.id || 0) + 1 } + + it "returns 404" do + expect(response).to have_http_status(:not_found) + end + end end describe "PATCH" do @@ -157,7 +165,7 @@ RSpec.describe CaseLogsController, type: :request do it "returns an error message" do json_response = JSON.parse(response.body) - expect(json_response["errors"]).to eq({ "person_1_age" => ["Tenant age must be between 0 and 120"] }) + expect(json_response["errors"]).to eq({ "person_1_age" => ["Tenant age must be an integer between 16 and 120"] }) end end diff --git a/yarn.lock b/yarn.lock index ecbe92bf6..a836c6244 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,15 @@ # yarn lockfile v1 +"@activeadmin/activeadmin@^2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@activeadmin/activeadmin/-/activeadmin-2.9.0.tgz#ad277ef4a49b250377afd3df615f9acc3c84d577" + integrity sha512-KZqr1MrvpCXN/8SCF4MgqGscmuWPHivz5RPHKR9R8bKIB0InUHm7tzQoQOT9ZdYuHCBkf3cqlzBoKNNNV3x3ww== + dependencies: + jquery "^3.4.1" + jquery-ui "^1.12.1" + jquery-ujs "^1.2.2" + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.14.5": version "7.14.5" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.14.5.tgz#23b08d740e83f49c5e59945fbf1b43e80bbf4edb" @@ -1800,6 +1809,25 @@ chalk@^2.0, chalk@^2.0.0, chalk@^2.4.1, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chart.js@>=3.0.2, chart.js@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.6.0.tgz#a87fce8431d4e7c5523d721f487f53aada1e42fe" + integrity sha512-iOzzDKePL+bj+ccIsVAgWQehCXv8xOKGbaU2fO/myivH736zcx535PGJzQGanvcSGVOqX6yuLZsN3ygcQ35UgQ== + +chartjs-adapter-date-fns@>=2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-2.0.0.tgz#5e53b2f660b993698f936f509c86dddf9ed44c6b" + integrity sha512-rmZINGLe+9IiiEB0kb57vH3UugAtYw33anRiw5kS2Tu87agpetDDoouquycWc9pRsKtQo5j+vLsYHyr8etAvFw== + +chartkick@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/chartkick/-/chartkick-4.1.0.tgz#a60b3ad55059195317f504fae607106d5cdfb78d" + integrity sha512-BBf1JWV7SRsxDng9Fh4MRGuj93VPkF8F1pcrOfbCD/ey+/Fyo+tFF8vqpGAjOyu7oNd4cc1IC/OUixUtPsfNmg== + optionalDependencies: + chart.js ">=3.0.2" + chartjs-adapter-date-fns ">=2.0.0" + date-fns ">=2.0.0" + "chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.1: version "3.5.2" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.2.tgz#dba3976fcadb016f66fd365021d91600d01c1e75" @@ -2336,6 +2364,11 @@ cyclist@^1.0.1: resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= +date-fns@>=2.0.0: + version "2.25.0" + resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.25.0.tgz#8c5c8f1d958be3809a9a03f4b742eba894fc5680" + integrity sha512-ovYRFnTrbGPD4nqaEqescPEv1mNwvt+UTqI3Ay9SzNtey9NZnYu6E2qCcBBgJ6/2VF1zGGygpyTDITqpQQ5e+w== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -3820,6 +3853,23 @@ jest-worker@^26.5.0: merge-stream "^2.0.0" supports-color "^7.0.0" +jquery-ui@^1.12.1: + version "1.13.0" + resolved "https://registry.yarnpkg.com/jquery-ui/-/jquery-ui-1.13.0.tgz#ab5ac65f37ca093c51b3478c4097f55bbc008f36" + integrity sha512-Osf7ECXNTYHtKBkn9xzbIf9kifNrBhfywFEKxOeB/OVctVmLlouV9mfc2qXCp6uyO4Pn72PXKOnj09qXetopCw== + dependencies: + jquery ">=1.8.0 <4.0.0" + +jquery-ujs@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/jquery-ujs/-/jquery-ujs-1.2.3.tgz#dcac6026ab7268e5ee41faf9d31c997cd4ddd603" + integrity sha512-59wvfx5vcCTHMeQT1/OwFiAj+UffLIwjRIoXdpO7Z7BCFGepzq9T9oLVeoItjTqjoXfUrHJvV7QU6pUR+UzOoA== + +"jquery@>=1.8.0 <4.0.0", jquery@^3.4.1: + version "3.6.0" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.6.0.tgz#c72a09f15c1bdce142f49dbf1170bdf8adac2470" + integrity sha512-JVzAR/AjBvVt2BmYhxRCSYysDsPcssdmTFnzyLEts9qNwmjmu4JTAMYubEfwVOSwpQ1I1sKKFcxhZCI2buerfw== + js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"