diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index d2a397df7..49c5618c7 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -8,7 +8,8 @@ def index if turbo_frame_request? per_page = params[:number_of_items_per_page].presence || 25 base_scope = authorized_scope(Organization.includes( - :windows_type, :organization_status, :sectors, :addresses, + :organization_status, :sectors, :addresses, + { windows_type: { categorizable_items: { category: :category_type } } }, { categorizable_items: { category: :category_type } }, logo_attachment: :blob )) @@ -189,6 +190,7 @@ def set_form_variables def set_index_variables @organization_statuses = OrganizationStatus.all + @age_range_categories = Category.age_ranges.published.ordered_by_position_and_name end def populations_served diff --git a/app/models/organization.rb b/app/models/organization.rb index b1ed44097..7350ef586 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -91,6 +91,26 @@ class Organization < ApplicationRecord scope :organization_ids, ->(organization_ids) { where(id: organization_ids.to_s.split("-").map(&:to_i)) } scope :project_ids, ->(project_ids) { where(id: project_ids.to_s.split("-").map(&:to_i)) } scope :published, -> { active } + # Filters by AgeRange category name(s), powering the unified "Age range" column + # on the index. An org matches a selected age range when EITHER it (or an + # affiliated person) is tagged with that age range, OR its windows audience + # semantically covers it. The windows-type leg keeps orgs that carry a windows + # audience but no age-range tags — or tags that disagree with it — in results. + scope :age_range_names, ->(names) do + return all if names.blank? + parsed = Array(names).flat_map { |n| n.to_s.split("--") }.map(&:strip).reject(&:blank?).map(&:downcase) + return all if parsed.empty? + + category_ids = Category.age_ranges.where("LOWER(categories.name) IN (?)", parsed).select(:id) + org_tagged = CategorizableItem.where(categorizable_type: "Organization", category_id: category_ids).select(:categorizable_id) + person_tagged = CategorizableItem.where(categorizable_type: "Person", category_id: category_ids).select(:categorizable_id) + via_affiliations = Affiliation.where(person_id: person_tagged).select(:organization_id) + windows_type_ids = WindowsType.joins(:categorizable_items) + .where(categorizable_items: { category_id: category_ids }) + .select(:id) + + where(id: org_tagged).or(where(id: via_affiliations)).or(where(windows_type_id: windows_type_ids)) + end def self.search_by_params(params) organizations = is_a?(ActiveRecord::Relation) ? self : all @@ -98,7 +118,7 @@ def self.search_by_params(params) organizations = organizations.sector_names_all(params[:sector_names_all]) if params[:sector_names_all].present? organizations = organizations.category_names_all(params[:category_names_all]) if params[:category_names_all].present? organizations = organizations.address(params[:address]) if params[:address].present? - organizations = organizations.windows_type_name(params[:windows_type_name]) if params[:windows_type_name].present? + organizations = organizations.age_range_names(params[:age_range_name]) if params[:age_range_name].present? organizations = organizations.organization_ids(params[:organization_ids]) if params[:organization_ids].present? organizations = organizations.where(organization_status_id: params[:organization_status_id]) if params[:organization_status_id].present? organizations @@ -265,6 +285,17 @@ def all_additional_age_groups collect_age_groups(:additional_age_groups) - all_primary_age_groups end + # Whether the windows audience adds age information the org's age-range tags + # don't already convey — drives the fallback windows-type badge in the index's + # age range column. True when the org has a windows type and either carries no + # age-range tags at all, or its tags omit an age range that windows type covers. + def windows_audience_unreflected_by_age_tags? + return false unless windows_type + tagged = all_primary_age_groups + all_additional_age_groups + return true if tagged.empty? + windows_type.tagged_age_range_categories.any? { |category| tagged.exclude?(category) } + end + remote_searchable_by :name # Returns the website as a clickable, scheme-qualified URL — prepending diff --git a/app/models/windows_type.rb b/app/models/windows_type.rb index 0b3c7239a..81bfa5086 100644 --- a/app/models/windows_type.rb +++ b/app/models/windows_type.rb @@ -16,4 +16,12 @@ class WindowsType < ApplicationRecord validates :name, presence: true validates :short_name, presence: true + + # AgeRange categories tagged on this windows type, read from already-loaded + # categorizable_items (no query) so the organizations index can compare them + # against an org's age-range tags without an N+1. Use the :age_ranges + # association when you want them ordered and don't already have the items loaded. + def tagged_age_range_categories + categorizable_items.map(&:category).compact.select { |category| category.category_type&.name == "AgeRange" } + end end diff --git a/app/views/organizations/_search_boxes.html.erb b/app/views/organizations/_search_boxes.html.erb index c06da8533..75375195a 100644 --- a/app/views/organizations/_search_boxes.html.erb +++ b/app/views/organizations/_search_boxes.html.erb @@ -10,12 +10,12 @@ focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
- <%= label_tag :windows_type_name, "Windows audience", class: "text-sm font-medium text-gray-700 mb-1 block" %> - <%= select_tag :windows_type_name, - options_for_select(WindowsType::TYPES.map { |wt| [wt, wt] }, - params[:windows_type_name]), - include_blank: "All types", - class: "w-40 rounded-md border border-gray-300 px-3 py-2 text-gray-800 shadow-sm + <%= label_tag :age_range_name, "Age range", class: "text-sm font-medium text-gray-700 mb-1 block" %> + <%= select_tag :age_range_name, + options_for_select(@age_range_categories.map { |category| [ category.name, category.name ] }, + params[:age_range_name]), + include_blank: "All age ranges", + class: "w-44 rounded-md border border-gray-300 px-3 py-2 text-gray-800 shadow-sm focus:border-blue-500 focus:ring focus:ring-blue-200 focus:outline-none" %>
diff --git a/app/views/organizations/organization_results.html.erb b/app/views/organizations/organization_results.html.erb index 2d229d962..fc8d298d4 100644 --- a/app/views/organizations/organization_results.html.erb +++ b/app/views/organizations/organization_results.html.erb @@ -38,12 +38,6 @@
- <% if organization.windows_type %> - - <%= organization.windows_type.short_name.titleize %> - - <% end %> - <% organization.sectors.each do |sector| %> <%= render "sectors/tagging_label", sector: sector %> <% end %> @@ -53,8 +47,19 @@ <% primary_age = organization.all_primary_age_groups %> <% additional_age = organization.all_additional_age_groups %> - <% if primary_age.any? || additional_age.any? %> - <%= render "shared/age_group_tags", primary: primary_age, additional: additional_age, compact: true %> + <% show_windows_audience = organization.windows_audience_unreflected_by_age_tags? %> + <% if primary_age.any? || additional_age.any? || show_windows_audience %> +
+ <% if primary_age.any? || additional_age.any? %> + <%= render "shared/age_group_tags", primary: primary_age, additional: additional_age, compact: true %> + <% end %> + <% if show_windows_audience %> + + <%= organization.windows_type.short_name.titleize %> + + <% end %> +
<% else %> -- <% end %> diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 75391fd1b..01c05bdf9 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -294,6 +294,76 @@ expect(organization.all_additional_age_groups).to be_empty end end + + describe "age-range filtering and windows-audience fallback" do + let(:age_type) { create(:category_type, name: "AgeRange", published: true) } + let!(:children) { create(:category, :published, category_type: age_type, name: "Children (0-12)") } + let!(:teens) { create(:category, :published, category_type: age_type, name: "Teens (13-17)") } + let!(:adults) { create(:category, :published, category_type: age_type, name: "Adults (18+)") } + + # Windows audiences with their semantic age-range coverage. + let(:children_audience) { create(:windows_type, :children).tap { |wt| wt.categories = [ children, teens ] } } + let(:adult_audience) { create(:windows_type, :adult).tap { |wt| wt.categories = [ adults ] } } + + describe ".age_range_names" do + it "matches orgs tagged with the age range on the org itself" do + org = create(:organization) + org.tag_age_groups(primary_ids: [ teens.id ], additional_ids: []) + other = create(:organization) + + results = Organization.age_range_names("Teens (13-17)") + expect(results).to include(org) + expect(results).not_to include(other) + end + + it "matches orgs tagged with the age range through an affiliated person" do + org = create(:organization) + person = create(:person) + create(:affiliation, organization: org, person: person) + person.tag_age_groups(primary_ids: [ children.id ], additional_ids: []) + + expect(Organization.age_range_names("Children (0-12)")).to include(org) + end + + it "matches orgs whose windows audience covers the age range even with no age tags" do + org = create(:organization, windows_type: adult_audience) + + expect(org.all_primary_age_groups).to be_empty + expect(Organization.age_range_names("Adults (18+)")).to include(org) + end + + it "excludes orgs whose windows audience does not cover the age range and that lack the tag" do + org = create(:organization, windows_type: adult_audience) + + expect(Organization.age_range_names("Children (0-12)")).not_to include(org) + end + end + + describe "#windows_audience_unreflected_by_age_tags?" do + it "is false when the org has no windows type" do + expect(create(:organization, windows_type: nil).windows_audience_unreflected_by_age_tags?).to be(false) + end + + it "is true when the org has a windows type but no age tags" do + org = create(:organization, windows_type: adult_audience) + expect(org.windows_audience_unreflected_by_age_tags?).to be(true) + end + + it "is true when the age tags omit an age range the windows audience covers" do + org = create(:organization, windows_type: children_audience) + org.tag_age_groups(primary_ids: [ children.id ], additional_ids: []) + + expect(org.windows_audience_unreflected_by_age_tags?).to be(true) + end + + it "is false when the age tags already reflect every age range the windows audience covers" do + org = create(:organization, windows_type: adult_audience) + org.tag_age_groups(primary_ids: [ adults.id ], additional_ids: []) + + expect(org.windows_audience_unreflected_by_age_tags?).to be(false) + end + end + end end RSpec.describe Organization, "scholarship index helpers" do