From c4eb923cf8a5596588a057719ee65aa6a46eecd3 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:38:52 -0400 Subject: [PATCH 1/7] remove user refence and use single cont for total --- app/controllers/people_controller.rb | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 65166cf7ce..00c00d5611 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -26,12 +26,15 @@ def index affiliations: :organization, event_registrations: :event, categorizable_items: { category: :category_type } - ).references(:user)) + )) filters = authorized_scope(params, as: :index).to_unsafe_h filtered = base_scope.search_by_params(filters) .order(:first_name, :last_name) - @count_display = count_display(filtered.count) - @people = filtered.paginate(page: params[:page], per_page: per_page) + filtered = filtered.distinct if filters[:contact_info].present? + + total = filtered.count + @count_display = count_display(total) + @people = filtered.paginate(page: params[:page], per_page: per_page, total_entries: total) render :people_results else From d2101644f433993b16667c1101f2c65740b72da1 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:15:05 -0400 Subject: [PATCH 2/7] simplify main search to name/email - move phone to admin for now --- app/controllers/people_controller.rb | 10 +++-- app/models/person.rb | 15 ++++++- app/policies/person_policy.rb | 2 +- app/views/people/_search_boxes.html.erb | 8 +++- spec/models/person_spec.rb | 46 ++++++++++++++++---- spec/requests/people_email_addresses_spec.rb | 10 +++++ spec/requests/people_search_spec.rb | 39 ++++++++++------- 7 files changed, 97 insertions(+), 33 deletions(-) diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 00c00d5611..163d4af03b 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -28,9 +28,10 @@ def index categorizable_items: { category: :category_type } )) filters = authorized_scope(params, as: :index).to_unsafe_h + contact_info = filters.delete(:contact_info) filtered = base_scope.search_by_params(filters) - .order(:first_name, :last_name) - filtered = filtered.distinct if filters[:contact_info].present? + filtered = filtered.search_name_email(contact_info) if contact_info.present? + filtered = filtered.order(:first_name, :last_name) total = filtered.count @count_display = count_display(total) @@ -44,7 +45,10 @@ def index def email_addresses authorize! Person, to: :manage? - people = authorized_scope(Person.includes(:user)).search_by_params(params.to_unsafe_h) + filters = params.to_unsafe_h + contact_info = filters.delete(:contact_info) + people = Person.includes(:user).search_by_params(filters) + people = people.search_name_email(contact_info) if contact_info.present? @email_addresses = people.filter_map { |person| person.preferred_email.presence }.uniq.sort end diff --git a/app/models/person.rb b/app/models/person.rb index c91a334c19..ebdfcee055 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -207,6 +207,12 @@ def known_email_2_type? = email_2_type.in?(CONTACT_TYPES) options :all, type: :text, default: true, default_operator: :or end + search_scope :search_name_email do + attributes :first_name, :legal_first_name, :last_name, :email, :email_2 + attributes all: [ :first_name, :legal_first_name, :last_name, :email, :email_2 ] + options :all, type: :text, default: true, default_operator: :or + end + FIRST_NAME_HIDDEN_PREFERENCES = %w[last_name_only].freeze LAST_NAME_HIDDEN_PREFERENCES = %w[first_name_last_initial first_name_only].freeze @@ -214,7 +220,6 @@ def known_email_2_type? = email_2_type.in?(CONTACT_TYPES) (people.first_name LIKE :pattern AND COALESCE(people.display_name_preference, '') NOT IN (:first_name_hidden)) OR (people.last_name LIKE :pattern AND COALESCE(people.display_name_preference, '') NOT IN (:last_name_hidden)) OR (people.profile_show_email AND (people.email LIKE :pattern OR users.email LIKE :pattern)) - OR (people.profile_show_phone AND (users.phone LIKE :pattern OR contact_methods.value LIKE :pattern)) SQL # Spaces and periods are dropped on both sides so an initialled name ("Cathy S.") finds itself. @@ -247,7 +252,7 @@ def known_email_2_type? = email_2_type.in?(CONTACT_TYPES) def self.shared_contact_search(query) terms = query.scan(SEARCH_TERM_PATTERN).map { |groups| groups.compact.first }.compact_blank terms.reduce(all) do |relation, term| - matches = unscoped.left_joins(:user, :active_phones).where( + matches = unscoped.left_joins(:user).where( SHARED_CONTACT_MATCH_SQL, pattern: "%#{sanitize_sql_like(term)}%", first_name_hidden: FIRST_NAME_HIDDEN_PREFERENCES, @@ -290,6 +295,11 @@ def self.shared_contact_search(query) tag_ids = Array(ids).reject(&:blank?) return all if tag_ids.empty? joins(:staff_taggings).where(staff_taggings: { staff_tag_id: tag_ids }).distinct } + scope :with_phone, ->(value) { + like = "%#{sanitize_sql_like(value.to_s.strip)}%" + where(id: ContactMethod.where(contactable_type: polymorphic_name) + .where("value LIKE ?", like) + .select(:contactable_id)) } scope :story_authors, -> { joins(:stories_as_author).distinct } scope :blog_contributors, -> { where(blog_contributor: true) } scope :placeholder_email, -> { where("email REGEXP ?", PlaceholderEmail.sql_pattern) } @@ -427,6 +437,7 @@ def self.shared_contact_search(query) def self.search_by_params(params) results = is_a?(ActiveRecord::Relation) ? self : all results = results.search(params[:contact_info]) if params[:contact_info].present? + results = results.with_phone(params[:phone]) if params[:phone].present? results = results.shared_contact_search(params[:shared_contact_info]) if params[:shared_contact_info].present? results = results.by_role(params[:role]) if params[:role].present? results = results.by_facilitator_status(params[:facilitator_status]) if params[:facilitator_status].present? diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index a60a03ee11..00f144149f 100644 --- a/app/policies/person_policy.rb +++ b/app/policies/person_policy.rb @@ -20,7 +20,7 @@ class PersonPolicy < ApplicationPolicy # behind the same admin gate the :index params_filter enforces. ADMIN_ONLY_FILTERS = %i[ role facilitator_status membership_status - staff_tag_ids topic_subscription_type_id + staff_tag_ids topic_subscription_type_id phone ].freeze SHARED_ONLY_FILTERS = { diff --git a/app/views/people/_search_boxes.html.erb b/app/views/people/_search_boxes.html.erb index c893543c1d..9e9994ab7a 100644 --- a/app/views/people/_search_boxes.html.erb +++ b/app/views/people/_search_boxes.html.erb @@ -8,7 +8,7 @@ <%= hidden_field_tag "organization_id", params[:organization_id] if params[:organization_id].present? %>
- <%= render "shared/filter_text", param: :contact_info, label: "Name, email, or phone (if shared)", submit: true, + <%= render "shared/filter_text", param: :contact_info, label: "Name or email", submit: true, wrapper_class: "w-full md:flex-1" %> @@ -36,7 +36,11 @@ <% if allowed_to?(:manage?, Person) %>

Admin-only filters

-
+
+ + <%= render "shared/filter_text", param: :phone, label: "Phone", submit: true, + wrapper_class: "w-full" %> + <%= render "shared/filter_select", param: :role, label: "Role", options: Person::ROLE_FILTER_OPTIONS, selected: params[:role], diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index 6a5f2d83db..65c991ec18 100644 --- a/spec/models/person_spec.rb +++ b/spec/models/person_spec.rb @@ -700,6 +700,24 @@ def phone_numbers expect(results).not_to include(person_alice) end + it 'search_name_email matches names and emails but not phones or addresses' do + create(:contact_method, contactable: person_alice, value: '555-0199') + create(:address, addressable: person_bob, street_address: '123 Hiddenway') + + expect(Person.search_name_email('Alice')).to include(person_alice) + expect(Person.search_name_email('alice@example')).to include(person_alice) + expect(Person.search_name_email('555-0199')).not_to include(person_alice) + expect(Person.search_name_email('Hiddenway')).not_to include(person_bob) + end + + it 'the broad search still matches phones and addresses' do + create(:contact_method, contactable: person_alice, value: '555-0199') + create(:address, addressable: person_bob, street_address: '123 Hiddenway') + + expect(Person.search('555-0199')).to include(person_alice) + expect(Person.search('Hiddenway')).to include(person_bob) + end + it 'filters by shared_contact_info matching a full name' do results = Person.search_by_params(shared_contact_info: 'Alice Smith') expect(results).to include(person_alice) @@ -714,20 +732,30 @@ def phone_numbers expect(Person.search_by_params(shared_contact_info: 'alice@example')).not_to include(person_alice) end - it 'filters by shared_contact_info matching a shared login phone' do + it 'no longer matches phones through shared_contact_info (phone search is admin-only)' do person_bob.update!(profile_show_phone: true) - person_bob.user.update!(phone: '555-0199') + create(:contact_method, contactable: person_bob, value: '555-0199') - expect(Person.search_by_params(shared_contact_info: '555-0199')).to include(person_bob) + expect(Person.search_by_params(shared_contact_info: '555-0199')).not_to include(person_bob) end - it 'filters by shared_contact_info matching terms across a name and a shared phone' do - person_alice.update!(profile_show_phone: true) - person_alice.user.update!(phone: '555-0199') + it 'filters by phone matching a contact method' do + create(:contact_method, contactable: person_alice, value: '555-0199') - results = Person.search_by_params(shared_contact_info: 'Alice 555-0199') - expect(results).to include(person_alice) - expect(results).not_to include(person_bob) + expect(Person.search_by_params(phone: '555-0199')).to include(person_alice) + expect(Person.search_by_params(phone: '555-0199')).not_to include(person_bob) + end + + it 'filters by phone matching an inactive contact method' do + create(:contact_method, contactable: person_alice, value: '555-0199', inactive: true) + + expect(Person.search_by_params(phone: '555-0199')).to include(person_alice) + end + + it 'does not match a login (users) phone' do + person_bob.user.update!(phone: '555-0199') + + expect(Person.search_by_params(phone: '555-0199')).not_to include(person_bob) end it 'filters by shared_contact_info treating a quoted phrase as one term' do diff --git a/spec/requests/people_email_addresses_spec.rb b/spec/requests/people_email_addresses_spec.rb index a0c4189a1f..003d7fbf16 100644 --- a/spec/requests/people_email_addresses_spec.rb +++ b/spec/requests/people_email_addresses_spec.rb @@ -35,6 +35,16 @@ expect(response.body).to include("No email addresses found.") end + it "does not match a phone (same narrow search as the people index)" do + person = create(:person, first_name: "Carol", last_name: "Phoner", + email: "carol@example.com", user: nil) + create(:contact_method, contactable: person, value: "555-0142") + + get email_addresses_people_path(contact_info: "555-0142") + + expect(response.body).not_to include("carol@example.com") + end + it "is admin-only" do sign_out admin sign_in create(:user) diff --git a/spec/requests/people_search_spec.rb b/spec/requests/people_search_spec.rb index cb2903d3ae..410b705d4d 100644 --- a/spec/requests/people_search_spec.rb +++ b/spec/requests/people_search_spec.rb @@ -15,6 +15,11 @@ expect(response).to have_http_status(:ok) end + it "shows the admin-only phone filter" do + get people_path + expect(response.body).to include('name="phone"') + end + it "breaks the person profile button out of the results frame" do get people_path, headers: turbo_headers expect_frame_breakout(response.body, person_path(person_alice)) @@ -162,6 +167,7 @@ def published_person(first_name) get people_path expect(response.body).not_to include("Admin-only filters") expect(response.body).not_to include("staff_tag_ids") + expect(response.body).not_to include('name="phone"') end it "ignores a staff tag filter passed by URL" do @@ -218,21 +224,17 @@ def search(term) expect(search("Hiddenway")).not_to include("Markedone") end - it "matches a shared phone" do + it "does not match phones (phone search is admin-only)" do marked.update!(profile_show_phone: true) create(:contact_method, contactable: marked, value: "555-0142") - expect(search("555-0142")).to include("Markedone") - end - - it "skips a phone the person hides" do - marked.update!(profile_show_phone: false) - create(:contact_method, contactable: marked, value: "555-0142") expect(search("555-0142")).not_to include("Markedone") end - it "skips an inactive phone" do - create(:contact_method, contactable: marked, value: "555-0142", inactive: true) - expect(search("555-0142")).not_to include("Markedone") + it "ignores a phone filter passed by URL" do + marked.update!(profile_show_phone: true) + create(:contact_method, contactable: marked, value: "555-0142") + get people_path, params: { phone: "555-0142" }, headers: turbo_headers + expect(response.body).to include("Markedone") end %w[first_name_last_initial first_name_only].each do |preference| @@ -348,6 +350,11 @@ def search(term) response.body end + def search_phone(term) + get people_path, params: { phone: term }, headers: turbo_headers + response.body + end + it "matches an email the person hides" do person.update!(email: "hidden@example.com") expect(search("hidden@example")).to include("Hiddenfacts") @@ -363,19 +370,19 @@ def search(term) expect(search("Legalname")).to include("Hiddenfacts") end - it "matches addresses" do + it "does not match addresses" do create(:address, addressable: person, street_address: "123 Hiddenway") - expect(search("Hiddenway")).to include("Hiddenfacts") + expect(search("Hiddenway")).not_to include("Hiddenfacts") end - it "matches a phone the person hides" do + it "matches a phone the person hides via the phone filter" do create(:contact_method, contactable: person, value: "555-0142") - expect(search("555-0142")).to include("Hiddenfacts") + expect(search_phone("555-0142")).to include("Hiddenfacts") end - it "matches an inactive phone" do + it "matches an inactive phone via the phone filter" do create(:contact_method, contactable: person, value: "555-0142", inactive: true) - expect(search("555-0142")).to include("Hiddenfacts") + expect(search_phone("555-0142")).to include("Hiddenfacts") end %w[first_name_last_initial first_name_only].each do |preference| From b66d922088338bec235322404c7018d1fd69c7d9 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:25:07 -0400 Subject: [PATCH 3/7] specs --- spec/models/person_spec.rb | 28 +------------------- spec/requests/people_email_addresses_spec.rb | 10 ------- spec/requests/people_search_spec.rb | 11 -------- 3 files changed, 1 insertion(+), 48 deletions(-) diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index 65c991ec18..d1629ebfef 100644 --- a/spec/models/person_spec.rb +++ b/spec/models/person_spec.rb @@ -700,22 +700,9 @@ def phone_numbers expect(results).not_to include(person_alice) end - it 'search_name_email matches names and emails but not phones or addresses' do - create(:contact_method, contactable: person_alice, value: '555-0199') - create(:address, addressable: person_bob, street_address: '123 Hiddenway') - + it 'search_name_email matches names and emails' do expect(Person.search_name_email('Alice')).to include(person_alice) expect(Person.search_name_email('alice@example')).to include(person_alice) - expect(Person.search_name_email('555-0199')).not_to include(person_alice) - expect(Person.search_name_email('Hiddenway')).not_to include(person_bob) - end - - it 'the broad search still matches phones and addresses' do - create(:contact_method, contactable: person_alice, value: '555-0199') - create(:address, addressable: person_bob, street_address: '123 Hiddenway') - - expect(Person.search('555-0199')).to include(person_alice) - expect(Person.search('Hiddenway')).to include(person_bob) end it 'filters by shared_contact_info matching a full name' do @@ -732,13 +719,6 @@ def phone_numbers expect(Person.search_by_params(shared_contact_info: 'alice@example')).not_to include(person_alice) end - it 'no longer matches phones through shared_contact_info (phone search is admin-only)' do - person_bob.update!(profile_show_phone: true) - create(:contact_method, contactable: person_bob, value: '555-0199') - - expect(Person.search_by_params(shared_contact_info: '555-0199')).not_to include(person_bob) - end - it 'filters by phone matching a contact method' do create(:contact_method, contactable: person_alice, value: '555-0199') @@ -752,12 +732,6 @@ def phone_numbers expect(Person.search_by_params(phone: '555-0199')).to include(person_alice) end - it 'does not match a login (users) phone' do - person_bob.user.update!(phone: '555-0199') - - expect(Person.search_by_params(phone: '555-0199')).not_to include(person_bob) - end - it 'filters by shared_contact_info treating a quoted phrase as one term' do person_mary = create(:person, first_name: 'Mary Ann', last_name: 'Lee') diff --git a/spec/requests/people_email_addresses_spec.rb b/spec/requests/people_email_addresses_spec.rb index 003d7fbf16..a0c4189a1f 100644 --- a/spec/requests/people_email_addresses_spec.rb +++ b/spec/requests/people_email_addresses_spec.rb @@ -35,16 +35,6 @@ expect(response.body).to include("No email addresses found.") end - it "does not match a phone (same narrow search as the people index)" do - person = create(:person, first_name: "Carol", last_name: "Phoner", - email: "carol@example.com", user: nil) - create(:contact_method, contactable: person, value: "555-0142") - - get email_addresses_people_path(contact_info: "555-0142") - - expect(response.body).not_to include("carol@example.com") - end - it "is admin-only" do sign_out admin sign_in create(:user) diff --git a/spec/requests/people_search_spec.rb b/spec/requests/people_search_spec.rb index 410b705d4d..918470e912 100644 --- a/spec/requests/people_search_spec.rb +++ b/spec/requests/people_search_spec.rb @@ -224,12 +224,6 @@ def search(term) expect(search("Hiddenway")).not_to include("Markedone") end - it "does not match phones (phone search is admin-only)" do - marked.update!(profile_show_phone: true) - create(:contact_method, contactable: marked, value: "555-0142") - expect(search("555-0142")).not_to include("Markedone") - end - it "ignores a phone filter passed by URL" do marked.update!(profile_show_phone: true) create(:contact_method, contactable: marked, value: "555-0142") @@ -370,11 +364,6 @@ def search_phone(term) expect(search("Legalname")).to include("Hiddenfacts") end - it "does not match addresses" do - create(:address, addressable: person, street_address: "123 Hiddenway") - expect(search("Hiddenway")).not_to include("Hiddenfacts") - end - it "matches a phone the person hides via the phone filter" do create(:contact_method, contactable: person, value: "555-0142") expect(search_phone("555-0142")).to include("Hiddenfacts") From a0e39b5a1301bc5ae7915dc7a225bcda14680388 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:29:43 -0400 Subject: [PATCH 4/7] clean up --- app/controllers/people_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 163d4af03b..58d3a53819 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -30,7 +30,7 @@ def index filters = authorized_scope(params, as: :index).to_unsafe_h contact_info = filters.delete(:contact_info) filtered = base_scope.search_by_params(filters) - filtered = filtered.search_name_email(contact_info) if contact_info.present? + filtered = filtered.search_name_email(contact_info) filtered = filtered.order(:first_name, :last_name) total = filtered.count @@ -48,7 +48,7 @@ def email_addresses filters = params.to_unsafe_h contact_info = filters.delete(:contact_info) people = Person.includes(:user).search_by_params(filters) - people = people.search_name_email(contact_info) if contact_info.present? + people = people.search_name_email(contact_info) @email_addresses = people.filter_map { |person| person.preferred_email.presence }.uniq.sort end From 286732ce0a378740517ba95bb0616645de40cadb Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:30:43 -0400 Subject: [PATCH 5/7] add full contact info --- app/controllers/people_controller.rb | 10 ++++-- app/models/person.rb | 20 +++++++---- app/policies/person_policy.rb | 4 +-- app/views/people/_search_boxes.html.erb | 12 +++---- spec/models/person_spec.rb | 25 ++++++++++--- spec/policies/person_policy_spec.rb | 3 +- spec/requests/people_email_addresses_spec.rb | 18 +++++++--- spec/requests/people_search_spec.rb | 37 +++++++++++--------- 8 files changed, 87 insertions(+), 42 deletions(-) diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 58d3a53819..7b8054dd93 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -28,9 +28,12 @@ def index categorizable_items: { category: :category_type } )) filters = authorized_scope(params, as: :index).to_unsafe_h + name_email = filters.delete(:name_email) contact_info = filters.delete(:contact_info) filtered = base_scope.search_by_params(filters) - filtered = filtered.search_name_email(contact_info) + filtered = filtered.search_name_email(name_email) + filtered = filtered.search_contact(contact_info) + filtered = filtered.distinct if contact_info.present? filtered = filtered.order(:first_name, :last_name) total = filtered.count @@ -46,9 +49,12 @@ def index def email_addresses authorize! Person, to: :manage? filters = params.to_unsafe_h + name_email = filters.delete(:name_email) contact_info = filters.delete(:contact_info) people = Person.includes(:user).search_by_params(filters) - people = people.search_name_email(contact_info) + people = people.search_name_email(name_email) + people = people.search_contact(contact_info) + people = people.distinct if contact_info.present? @email_addresses = people.filter_map { |person| person.preferred_email.presence }.uniq.sort end diff --git a/app/models/person.rb b/app/models/person.rb index ebdfcee055..407121cee7 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -213,6 +213,20 @@ def known_email_2_type? = email_2_type.in?(CONTACT_TYPES) options :all, type: :text, default: true, default_operator: :or end + search_scope :search_contact do + scope { left_joins(:contact_methods, :addresses) } + attributes contact_methods_phone: "contact_methods.value" + attributes address_street: "addresses.street_address" + attributes address_city: "addresses.city" + attributes address_state: "addresses.state" + attributes address_zip: "addresses.zip_code" + attributes address_phone: "addresses.phone" + attributes all: [ "contact_methods.value", + "addresses.street_address", "addresses.city", "addresses.state", + "addresses.zip_code", "addresses.phone" ] + options :all, type: :text, default: true, default_operator: :or + end + FIRST_NAME_HIDDEN_PREFERENCES = %w[last_name_only].freeze LAST_NAME_HIDDEN_PREFERENCES = %w[first_name_last_initial first_name_only].freeze @@ -295,11 +309,6 @@ def self.shared_contact_search(query) tag_ids = Array(ids).reject(&:blank?) return all if tag_ids.empty? joins(:staff_taggings).where(staff_taggings: { staff_tag_id: tag_ids }).distinct } - scope :with_phone, ->(value) { - like = "%#{sanitize_sql_like(value.to_s.strip)}%" - where(id: ContactMethod.where(contactable_type: polymorphic_name) - .where("value LIKE ?", like) - .select(:contactable_id)) } scope :story_authors, -> { joins(:stories_as_author).distinct } scope :blog_contributors, -> { where(blog_contributor: true) } scope :placeholder_email, -> { where("email REGEXP ?", PlaceholderEmail.sql_pattern) } @@ -437,7 +446,6 @@ def self.shared_contact_search(query) def self.search_by_params(params) results = is_a?(ActiveRecord::Relation) ? self : all results = results.search(params[:contact_info]) if params[:contact_info].present? - results = results.with_phone(params[:phone]) if params[:phone].present? results = results.shared_contact_search(params[:shared_contact_info]) if params[:shared_contact_info].present? results = results.by_role(params[:role]) if params[:role].present? results = results.by_facilitator_status(params[:facilitator_status]) if params[:facilitator_status].present? diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index 00f144149f..aabf1ecdb3 100644 --- a/app/policies/person_policy.rb +++ b/app/policies/person_policy.rb @@ -20,11 +20,11 @@ class PersonPolicy < ApplicationPolicy # behind the same admin gate the :index params_filter enforces. ADMIN_ONLY_FILTERS = %i[ role facilitator_status membership_status - staff_tag_ids topic_subscription_type_id phone + staff_tag_ids topic_subscription_type_id contact_info ].freeze SHARED_ONLY_FILTERS = { - contact_info: :shared_contact_info, + name_email: :shared_contact_info, sector_ids: :shared_sector_ids, age_range_ids: :shared_age_range_ids, organization_name: :shared_organization_name, diff --git a/app/views/people/_search_boxes.html.erb b/app/views/people/_search_boxes.html.erb index 9e9994ab7a..c59e91557d 100644 --- a/app/views/people/_search_boxes.html.erb +++ b/app/views/people/_search_boxes.html.erb @@ -7,8 +7,8 @@ class: "space-y-4") do |f| %> <%= hidden_field_tag "organization_id", params[:organization_id] if params[:organization_id].present? %>
- - <%= render "shared/filter_text", param: :contact_info, label: "Name or email", submit: true, + + <%= render "shared/filter_text", param: :name_email, label: "Name or email", submit: true, wrapper_class: "w-full md:flex-1" %> @@ -37,10 +37,6 @@

Admin-only filters

- - <%= render "shared/filter_text", param: :phone, label: "Phone", submit: true, - wrapper_class: "w-full" %> - <%= render "shared/filter_select", param: :role, label: "Role", options: Person::ROLE_FILTER_OPTIONS, selected: params[:role], @@ -74,6 +70,10 @@ selected: Array(params[:topic_subscription_type_id]).map(&:to_i), placeholder: "Any topic", wrapper_class: "w-full", hint: link_to("Manage topics", topic_subscription_types_path, class: eyebrow_link_class, target: "_blank", rel: "noopener") %> + + + <%= render "shared/filter_text", param: :contact_info, label: "Contact info", submit: true, + wrapper_class: "w-full" %>
<% end %> diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index d1629ebfef..e13a437648 100644 --- a/spec/models/person_spec.rb +++ b/spec/models/person_spec.rb @@ -719,17 +719,32 @@ def phone_numbers expect(Person.search_by_params(shared_contact_info: 'alice@example')).not_to include(person_alice) end - it 'filters by phone matching a contact method' do + it 'search_contact matches a contact method' do create(:contact_method, contactable: person_alice, value: '555-0199') - expect(Person.search_by_params(phone: '555-0199')).to include(person_alice) - expect(Person.search_by_params(phone: '555-0199')).not_to include(person_bob) + expect(Person.search_contact('555-0199')).to include(person_alice) + expect(Person.search_contact('555-0199')).not_to include(person_bob) end - it 'filters by phone matching an inactive contact method' do + it 'search_contact matches an inactive contact method' do create(:contact_method, contactable: person_alice, value: '555-0199', inactive: true) - expect(Person.search_by_params(phone: '555-0199')).to include(person_alice) + expect(Person.search_contact('555-0199')).to include(person_alice) + end + + it 'search_contact matches an address' do + create(:address, addressable: person_bob, street_address: '123 Hiddenway', city: 'Portland') + + expect(Person.search_contact('Hiddenway')).to include(person_bob) + end + + it 'search_contact supports attribute prefixes' do + create(:address, addressable: person_bob, street_address: '123 Hiddenway', city: 'Portland') + create(:contact_method, contactable: person_alice, value: '555-0199') + + expect(Person.search_contact('address_city:portland')).to include(person_bob) + expect(Person.search_contact('address_city:portland')).not_to include(person_alice) + expect(Person.search_contact('contact_methods_phone:555')).to include(person_alice) end it 'filters by shared_contact_info treating a quoted phrase as one term' do diff --git a/spec/policies/person_policy_spec.rb b/spec/policies/person_policy_spec.rb index 9a75f946df..95232c6b14 100644 --- a/spec/policies/person_policy_spec.rb +++ b/spec/policies/person_policy_spec.rb @@ -412,7 +412,8 @@ def in_production describe "params_filter(:index)" do let(:filters) do ActionController::Parameters.new( - contact_info: "ada", role: "story_authors", staff_tag_ids: [ "1" ], + name_email: "ada", contact_info: "555-0142", + role: "story_authors", staff_tag_ids: [ "1" ], topic_subscription_type_id: [ "2" ], facilitator_status: "active", membership_status: "paid", sector_ids: [ "4" ], age_range_ids: [ "5" ], organization_name: "Acme", organization_id: "3" diff --git a/spec/requests/people_email_addresses_spec.rb b/spec/requests/people_email_addresses_spec.rb index a0c4189a1f..8421d88f48 100644 --- a/spec/requests/people_email_addresses_spec.rb +++ b/spec/requests/people_email_addresses_spec.rb @@ -12,13 +12,23 @@ create(:person, first_name: "Bob", last_name: "Builder", email: "bob@example.com", user: nil) - get email_addresses_people_path(contact_info: "Alice") + get email_addresses_people_path(name_email: "Alice") expect(response).to have_http_status(:success) expect(response.body).to include("alice@example.com") expect(response.body).not_to include("bob@example.com") end + it "matches contact details like the people index" do + person = create(:person, first_name: "Carol", last_name: "Phoner", + email: "carol@example.com", user: nil) + create(:contact_method, contactable: person, value: "555-0142") + + get email_addresses_people_path(contact_info: "555-0142") + + expect(response.body).to include("carol@example.com") + end + it "falls back to the person's preferred email when the email column is blank" do create(:person, email: nil, user: create(:user, email: "dana@example.com")) create(:person, email: nil, email_2: "rio@example.com", user: nil) @@ -30,7 +40,7 @@ end it "shows the empty state when no people match" do - get email_addresses_people_path(contact_info: "nobody-matches-this") + get email_addresses_people_path(name_email: "nobody-matches-this") expect(response.body).to include("No email addresses found.") end @@ -47,10 +57,10 @@ describe "Email addresses link on the filtered index frame" do it "carries the active filter into the Email addresses link" do - get people_path(contact_info: "Alice"), headers: { "Turbo-Frame" => "people_results" } + get people_path(name_email: "Alice"), headers: { "Turbo-Frame" => "people_results" } expect(response).to have_http_status(:success) - expect(response.body).to include(email_addresses_people_path(contact_info: "Alice")) + expect(response.body).to include(email_addresses_people_path(name_email: "Alice")) end end end diff --git a/spec/requests/people_search_spec.rb b/spec/requests/people_search_spec.rb index 918470e912..78786ce669 100644 --- a/spec/requests/people_search_spec.rb +++ b/spec/requests/people_search_spec.rb @@ -15,9 +15,9 @@ expect(response).to have_http_status(:ok) end - it "shows the admin-only phone filter" do + it "shows the admin-only contact info filter" do get people_path - expect(response.body).to include('name="phone"') + expect(response.body).to include('name="contact_info"') end it "breaks the person profile button out of the results frame" do @@ -32,8 +32,8 @@ expect(response.body).to include("Bob") end - it "filters by contact_info" do - get people_path, params: { contact_info: "Alice" }, headers: turbo_headers + it "filters by name_email" do + get people_path, params: { name_email: "Alice" }, headers: turbo_headers expect(response).to have_http_status(:ok) expect(response.body).to include("Alice") expect(response.body).not_to include("Bob") @@ -167,7 +167,7 @@ def published_person(first_name) get people_path expect(response.body).not_to include("Admin-only filters") expect(response.body).not_to include("staff_tag_ids") - expect(response.body).not_to include('name="phone"') + expect(response.body).not_to include('name="contact_info"') end it "ignores a staff tag filter passed by URL" do @@ -187,7 +187,7 @@ def published_person(first_name) end it "still applies the filters the bar does offer them" do - get people_path, params: { contact_info: "Markedone" }, headers: turbo_headers + get people_path, params: { name_email: "Markedone" }, headers: turbo_headers expect(response.body).to include("Markedone") expect(response.body).not_to include("Plainone") @@ -195,7 +195,7 @@ def published_person(first_name) describe "contact info search" do def search(term) - get people_path, params: { contact_info: term }, headers: turbo_headers + get people_path, params: { name_email: term }, headers: turbo_headers response.body end @@ -224,10 +224,10 @@ def search(term) expect(search("Hiddenway")).not_to include("Markedone") end - it "ignores a phone filter passed by URL" do + it "ignores a contact details filter passed by URL" do marked.update!(profile_show_phone: true) create(:contact_method, contactable: marked, value: "555-0142") - get people_path, params: { phone: "555-0142" }, headers: turbo_headers + get people_path, params: { contact_info: "555-0142" }, headers: turbo_headers expect(response.body).to include("Markedone") end @@ -340,12 +340,12 @@ def search(term) let!(:person) { create(:person, first_name: "Hiddenfacts", profile_show_email: false, profile_show_phone: false) } def search(term) - get people_path, params: { contact_info: term }, headers: turbo_headers + get people_path, params: { name_email: term }, headers: turbo_headers response.body end - def search_phone(term) - get people_path, params: { phone: term }, headers: turbo_headers + def search_contact_details(term) + get people_path, params: { contact_info: term }, headers: turbo_headers response.body end @@ -364,14 +364,19 @@ def search_phone(term) expect(search("Legalname")).to include("Hiddenfacts") end - it "matches a phone the person hides via the phone filter" do + it "matches a contact method via the contact info filter" do create(:contact_method, contactable: person, value: "555-0142") - expect(search_phone("555-0142")).to include("Hiddenfacts") + expect(search_contact_details("555-0142")).to include("Hiddenfacts") end - it "matches an inactive phone via the phone filter" do + it "matches an inactive contact method via the contact info filter" do create(:contact_method, contactable: person, value: "555-0142", inactive: true) - expect(search_phone("555-0142")).to include("Hiddenfacts") + expect(search_contact_details("555-0142")).to include("Hiddenfacts") + end + + it "matches an address via the contact info filter" do + create(:address, addressable: person, street_address: "123 Hiddenway") + expect(search_contact_details("Hiddenway")).to include("Hiddenfacts") end %w[first_name_last_initial first_name_only].each do |preference| From a24b2fa3b2284b5d08143a6257c5bad6ac694473 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:54:45 -0400 Subject: [PATCH 6/7] add tooltip --- app/views/people/_search_boxes.html.erb | 3 ++- app/views/shared/_filter_text.html.erb | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/views/people/_search_boxes.html.erb b/app/views/people/_search_boxes.html.erb index c59e91557d..03a845881a 100644 --- a/app/views/people/_search_boxes.html.erb +++ b/app/views/people/_search_boxes.html.erb @@ -73,7 +73,8 @@ <%= render "shared/filter_text", param: :contact_info, label: "Contact info", submit: true, - wrapper_class: "w-full" %> + wrapper_class: "w-full", + tooltip: "Searches phone, address, city, state, and ZIP." %>
<% end %> diff --git a/app/views/shared/_filter_text.html.erb b/app/views/shared/_filter_text.html.erb index de7964b522..f380591174 100644 --- a/app/views/shared/_filter_text.html.erb +++ b/app/views/shared/_filter_text.html.erb @@ -2,6 +2,7 @@ bars, so every free-text filter renders the same markup. Locals: param – input name/id (Symbol or String) label – optional field label (omitted when nil) + tooltip – optional help text shown as a title on an info icon beside the label placeholder – optional input placeholder value – optional input value (defaults to params[param]) submit – true adds the magnifier search button inside the field @@ -17,7 +18,14 @@ placeholder: local_assigns[:placeholder], **local_assigns.fetch(:input_options, {}) %> <%= tag.div class: local_assigns.fetch(:wrapper_class, "w-full md:w-48") do %> - <%= label_tag param, label_text, class: local_assigns.fetch(:label_class, search_label_class) if label_text %> + <% if label_text %> + <%= label_tag param, nil, class: local_assigns.fetch(:label_class, search_label_class) do %> + <%= label_text %> + <% if local_assigns[:tooltip].present? %> + + <% end %> + <% end %> + <% end %> <% if submit %>
<%= input %> From cbd995cbbaf67d216de023c93cb32107c1952eb3 Mon Sep 17 00:00:00 2001 From: Justin Miller <16829344+jmilljr24@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:29:01 -0400 Subject: [PATCH 7/7] add country and county --- app/models/person.rb | 15 ++++++++------- app/views/people/_search_boxes.html.erb | 2 +- spec/models/person_spec.rb | 6 +++--- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/app/models/person.rb b/app/models/person.rb index 407121cee7..466222c046 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -215,15 +215,16 @@ def known_email_2_type? = email_2_type.in?(CONTACT_TYPES) search_scope :search_contact do scope { left_joins(:contact_methods, :addresses) } - attributes contact_methods_phone: "contact_methods.value" - attributes address_street: "addresses.street_address" - attributes address_city: "addresses.city" - attributes address_state: "addresses.state" - attributes address_zip: "addresses.zip_code" - attributes address_phone: "addresses.phone" + attributes phone: "contact_methods.value" + attributes street: "addresses.street_address" + attributes city: "addresses.city" + attributes state: "addresses.state" + attributes zip: "addresses.zip_code" + attributes county: "addresses.county" + attributes country: "addresses.country" attributes all: [ "contact_methods.value", "addresses.street_address", "addresses.city", "addresses.state", - "addresses.zip_code", "addresses.phone" ] + "addresses.zip_code", "addresses.county", "addresses.country" ] options :all, type: :text, default: true, default_operator: :or end diff --git a/app/views/people/_search_boxes.html.erb b/app/views/people/_search_boxes.html.erb index 03a845881a..59fca28040 100644 --- a/app/views/people/_search_boxes.html.erb +++ b/app/views/people/_search_boxes.html.erb @@ -74,7 +74,7 @@ <%= render "shared/filter_text", param: :contact_info, label: "Contact info", submit: true, wrapper_class: "w-full", - tooltip: "Searches phone, address, city, state, and ZIP." %> + tooltip: "Prefixes: phone:, street:, city:, state:, zip:, county:, country:" %>
<% end %> diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index e13a437648..acfac6717a 100644 --- a/spec/models/person_spec.rb +++ b/spec/models/person_spec.rb @@ -742,9 +742,9 @@ def phone_numbers create(:address, addressable: person_bob, street_address: '123 Hiddenway', city: 'Portland') create(:contact_method, contactable: person_alice, value: '555-0199') - expect(Person.search_contact('address_city:portland')).to include(person_bob) - expect(Person.search_contact('address_city:portland')).not_to include(person_alice) - expect(Person.search_contact('contact_methods_phone:555')).to include(person_alice) + expect(Person.search_contact('city:portland')).to include(person_bob) + expect(Person.search_contact('city:portland')).not_to include(person_alice) + expect(Person.search_contact('phone:555')).to include(person_alice) end it 'filters by shared_contact_info treating a quoted phrase as one term' do