From b90a003a44dea44eee452687a3dc8c28f56cea50 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 13:32:46 -0400 Subject: [PATCH 1/6] Teach every search box one grammar, and one prefixed way to ask MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four search boxes had grown four dialects. The people index took SearchCop's `or`; the roster keyword box took neither `or` nor the `--` that means "either" in every tag filter, workshop search and picker in this app; and typing a second word widened the people index while narrowing nothing anywhere else. Now the words inside an alternative all have to match in any order, `--` keeps either, quotes make a phrase, and one generated hint sits beside every box saying so. The two rules combine, which is the whole reason expand_alternatives parenthesises each alternative before SearchCop sees it. ContactSearchable holds the prefix map once, so the people index, the attendees page and the organizations index offer the same words and a prefix added here reaches all three. `type:` narrows to a work or personal address and, because SearchCop ANDs prefixes onto one joined row, `type: personal city: austin` asks for an address that is both — one prefix instead of a parallel personal_* set, composing with every field. The organizations box keeps its LA district columns by sitting them beside `type:` out of the bare-term catch-all, where they can be matched as the integers they are. Searching reads live addresses only. The standing chain settles which address SHOWS a person; it is the wrong rule for finding them, because an admin holding their old address would search it and be told nobody lives there. The attendees page had two City filters meaning different things side by side. The work-location one becomes a prefixed box of its own, "Where they work", which is also what lets locality, zip, school district and the LA districts be filtered at all. A visible dropdown still wins over the same field typed into the box, and a bare city param stays honoured as a chip so an older link narrows visibly rather than silently. Person.search_by_params no longer answers contact_info: every index reaches ContactSearchable from the URL param directly, so the branch was a second, older meaning for the same word. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 3 +- app/controllers/events_controller.rb | 11 +- app/controllers/people_controller.rb | 2 - app/helpers/events_helper.rb | 2 +- app/models/concerns/contact_searchable.rb | 105 ++++++++++++++++++ app/models/event_registration.rb | 42 ++++--- app/models/organization.rb | 31 +----- app/models/person.rb | 28 ++--- app/services/attendees_active_filters.rb | 8 +- app/services/person_locations.rb | 50 +++++++-- app/services/reminder_recipient_filter.rb | 11 +- app/views/events/_attendees_search.html.erb | 27 ++++- app/views/events/_registrant_filters.html.erb | 1 + app/views/people/_search_boxes.html.erb | 4 +- ...026-10-25-one-search-syntax-everywhere.yml | 19 ++++ ...5-prefixed-contact-search-on-attendees.yml | 15 +++ spec/models/organization_spec.rb | 24 ++-- spec/models/person_spec.rb | 28 ++--- spec/requests/events/attendees_spec.rb | 65 ++++++++++- spec/requests/events_spec.rb | 37 ++++++ spec/requests/people_search_spec.rb | 40 +++++++ spec/services/person_locations_spec.rb | 39 +++++++ .../reminder_recipient_filter_spec.rb | 11 ++ 23 files changed, 487 insertions(+), 116 deletions(-) create mode 100644 app/models/concerns/contact_searchable.rb create mode 100644 db/seeds/features/2026-10-25-one-search-syntax-everywhere.yml create mode 100644 db/seeds/features/2026-10-25-prefixed-contact-search-on-attendees.yml diff --git a/AGENTS.md b/AGENTS.md index 0243f14d11..2e14850f24 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,6 +146,7 @@ This codebase (Rails 8.1) | `AgeGroupTaggable` | Splits AgeRange category taggings into primary/additional via `categorizable_items.is_primary` (Person). `replace: true` treats the submitted set as the whole truth; demotions save row by row so they reach the change log | | `AhoyTrackable` | Event tracking integration. Included by the tag join models (`SectorableItem`, `CategorizableItem`) too, so a submission's tag adds/removals/primary flips reach `FormSubmissionChanges` | | `CategoriesTaggable` | Enforces a single primary category via `categorizable_items.is_primary` (Story, StoryIdea); any type validates, but the story forms and `TagAssignable` star only `AUDIENCE_CATEGORY_TYPES` (AgeRange + StoryPopulation). Also `audience_categories` | +| `ContactSearchable` | One definition of the prefixed "find them by where they are" search, shared by the people index, the attendees index and the organizations index (`searches_contact :contact_methods, :active_addresses` → a `search_contact` scope wrapping SearchCop's `contact_matches`, distinct built in). `CONTACT_SEARCH_FIELDS` maps the prefix someone types to its column(s) — phone, street, city, state, zip, county, country, locality, district, the three LA districts, and `type:` for work/personal/mailing. SearchCop ANDs the prefixes onto the **same joined address**, so `type: personal city: austin` is how you search a personal city alone. `SCOPING_PREFIXES` stay out of the bare-term catch-all (`type:` is a word streets are called; the LA districts are integer columns). Searches **live addresses only** (`active_addresses`) — a retired address is still the right place to show someone (`Address::STANDING`) but a wrong place to find them. Grammar, shared by EVERY search box in the app: several words AND (typing more finds less, in any word order), `--` means either (`ALTERNATIVE_SEPARATOR`), quotes make a phrase, commas become spaces unless the query is quoted. The two combine — `amy jones--mary davis` is `(amy AND jones) OR (mary AND davis)`, which is why `expand_alternatives` parenthesises each alternative before SearchCop sees it: unparenthesised, `or` binds to the last word alone and the query matches nobody. The non-SearchCop boxes reach the same grammar through `alternatives` (`EventRegistration.keyword` ANDs each word's whole OR block; `ReminderRecipientFilter#any_term?` ANDs the words of each alternative). `SEARCH_SYNTAX_HINT` / `CONTACT_SEARCH_HINT` caption every box from these constants so help text can't drift. Only the prefixes whose column sits on an association the model names get defined, so Organization (addresses, no contact methods) includes it with one line | | `Communicable` | Gives a record the `notifications` association, nested attributes, a default `communications_scope`, and a `before_validation` addressing a hand-logged communication to the record's `communications_email`. Included by every model whose form renders `shared/_comments_and_communications` | | `AuthorCreditable` | Author attribution. Credits are formatted by the credited **person's profile** (`Person#display_name_preference`), not by the record. The record's `author_credit_preference` is the consent snapshot taken at create time and is human-editable only on the author credit divergences page. Anonymity is a one-way latch: the profile or the record can set it, neither can strip it from the other. `credits_to_org` flips the unattributed label to "AWBW Staff"; `credits_creator` (idea models, workshop logs) credits the submitting account's person when no author is named | | `Featureable` | `featured`, `publicly_featured` scopes | @@ -237,7 +238,7 @@ comparison — add a rule if none fits (e.g. `PersonPolicy#own_profile?`). Conve - `EventProgramStatusReport` — Cross-event program-status report grouped by calendar year: how many organizations were New / Ongoing / Reinstate at each facilitator training, counted both as organization-trainings (summed rows) and as distinct organizations classified at their earliest training. Sibling of `EventRevenueReport`/`EventParticipationReport`/`EventScholarshipReport` (includes `ReportPeriods`); powers the `events#program_statuses` page and the reports-hub program-status card - `EventParticipationReport` — Cross-event participation report grouped by calendar year (unique people trained vs attended seats vs per-status outcome counts, chart series) for the events participation page; sibling of `EventRevenueReport` - `AttendeesRoster` — Cross-event counterpart to `EventDashboard`: builds the per-registrant lookup maps the shared `events/_registrant_roster` partial reads (sector/age/org/status/location/scholarship/CE plus the events-attended column) for a paginated page of people; backs the `events#attendees` index. Takes `events:` + `registrations:` — the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope — so a person's columns show what's in scope rather than their whole history, and never an event the viewer can't see -- `PersonLocations` — Where each person is on a date (`on:`, default today) — where they work, else where they live: every affiliation active on that date (`Affiliation.active_by_date_on`, or `.active` for today), at its picked address (`Affiliation#organization_address`), else its org's oldest active address — every active affiliation counts; with none, the one address that stands for the person themselves (`Address.ranked_for_owner` / `Address::STANDING` — active work, else latest-edited active personal, else inactive personal, else inactive work; work and personal swap below the active line because a current work address is the sharpest thing we know and a retired one the stalest. Written once in Ruby and compiled into the ORDER BY; `AddressDecorator#saved_address` sorts a loaded association by the same list. Retired addresses count, because placing someone by a stale address beats leaving them off the map). Each row carries every `LOCATION_FIELDS` value the address holds — city, county, state, country, locality, zip, school district and the three LA districts, i.e. everything bar the street and phone — so a new chart, count or filter needs only a field name: `#registrant_ids_by(field)` groups ids by any of them (`#registrant_ids_by_state` is US-only for the map, `#registrant_ids_by_city_label` keys on "City, State"), and `#matching(**fields)` tests ONE address per row against the whole filter set at once — chaining two calls is what lets someone working in two places answer a city from one and a county from the other. `.filters_from(**params)` folds a County option's own state ("CA|Los Angeles" on the roster, "CA::Los Angeles" on the attendees index) into `state:` and returns nil when a State filter contradicts it. `#candidate_values(*fields)` answers filter option lists straight from the org/registrant address tables (two grouped queries, a superset) so a whole-population dropdown never builds a row per person. Takes a person-id relation (kept as a subquery, so a whole-population lookup never inlines every id) or an explicit id array. Event pages pass the event's start date so a past event doesn't move when someone changes jobs; cross-event pages and reports use today. The single source for every person location figure and filter: the charts' cities, states, and countries (`EventDashboard` on the event date, `AttendeesBreakdowns` on `as_of`), the roster/reminder City/State/County filters (`EventRegistration.located_in(on:, ...)`, which takes them all at once, and `ReminderRecipientFilter`), the attendees index's City/State/County filters and `country`/`org_city` drill-ins (the filtered event's date, else today), and `EventParticipationReport#demographics` (today) +- `PersonLocations` — Where each person is on a date (`on:`, default today) — where they work, else where they live: every affiliation active on that date (`Affiliation.active_by_date_on`, or `.active` for today), at its picked address (`Affiliation#organization_address`), else its org's oldest active address — every active affiliation counts; with none, the one address that stands for the person themselves (`Address.ranked_for_owner` / `Address::STANDING` — active work, else latest-edited active personal, else inactive personal, else inactive work; work and personal swap below the active line because a current work address is the sharpest thing we know and a retired one the stalest. Written once in Ruby and compiled into the ORDER BY; `AddressDecorator#saved_address` sorts a loaded association by the same list. Retired addresses count, because placing someone by a stale address beats leaving them off the map). Each row carries every `LOCATION_FIELDS` value the address holds — city, county, state, country, locality, zip, school district and the three LA districts, i.e. everything bar the street and phone — so a new chart, count or filter needs only a field name: `#registrant_ids_by(field)` groups ids by any of them (`#registrant_ids_by_state` is US-only for the map, `#registrant_ids_by_city_label` keys on "City, State"), and `#matching(**fields)` tests ONE address per row against the whole filter set at once (plus `any:` terms, which may match any field) — chaining two calls is what lets someone working in two places answer a city from one and a county from the other. `.parse_query` reads the prefixed "Where they work" box (`FIELD_PREFIXES` — city, county, state, country, locality, zip, district, the LA districts; an unknown prefix or bare word becomes an any-field term rather than an error), and `.filters_from(**params)` folds a County option's own state ("CA|Los Angeles" on the roster, "CA::Los Angeles" on the attendees index) into `state:` and returns nil when a State filter contradicts it. `#candidate_values(*fields)` answers filter option lists straight from the org/registrant address tables (two grouped queries, a superset) so a whole-population dropdown never builds a row per person. Takes a person-id relation (kept as a subquery, so a whole-population lookup never inlines every id) or an explicit id array. Event pages pass the event's start date so a past event doesn't move when someone changes jobs; cross-event pages and reports use today. The single source for every person location figure and filter: the charts' cities, states, and countries (`EventDashboard` on the event date, `AttendeesBreakdowns` on `as_of`), the roster/reminder City/State/County filters (`EventRegistration.located_in(on:, ...)`, which takes them all at once, and `ReminderRecipientFilter`), the attendees index's City/State/County filters and `country`/`org_city` drill-ins (the filtered event's date, else today), and `EventParticipationReport#demographics` (today) - `AttendeesBreakdowns` — Aggregate counterpart to `EventDashboard`'s breakdown methods: computes the chart datasets (sectors, age groups, locations, program status, life experiences, settings, organizations, scholarship/CE) over an arbitrary people set — tags from profiles, cities/states/countries from `PersonLocations` — for the shared `events/_registrant_breakdowns` partial. Backs the `events#attendees` index charts (cross-event; `events:` / `registrations:` = the index's current filter scopes, already narrowed by `EventPolicy`'s `:reportable` scope) and the `events#recipients` charts frame (one event's scholarship recipients, `registrations:` = `EventRegistration.active` so it counts them regardless of attendance, `as_of:` = that event's start date so program status reads the same verdict its dashboard does). Also exposes `*_registrant_ids_by_*` maps mirroring `EventDashboard`'s, so a breakdown row can drill in by person id — they regroup rows already loaded for the counts, adding no queries - `AttendeesActiveFilters` — Human-readable, removable-chip descriptors for a page's "drill-in" filters (registrant_ids, organization, city, age group / life experience / setting categories, country, school district, scholarship, CE, payment status, scholarship funding source, sector, state) — params that narrow a list without a field in a visible filter form. `CHIP_PARAMS` is the attendees index's set (omitting anything with its own control); pass `chip_params:` for a page with no filter form at all — `ROSTER_CHIP_PARAMS` for the per-event roster, `%w[ registrant_ids ]` for scholarship recipients - `ReportPeriods` — Shared module (included by `EventRevenueReport` and `EventParticipationReport`) resolving the reporting-hub period toggle (this year / last year / all time) to a metric scope + label for the summary cards diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 97cf0591d4..9c3014414d 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -845,7 +845,10 @@ def filtered_attendees scope = Person.where(id: attendee_registrations.select(:registrant_id)) # Dash-joined person ids, e.g. from the reports-hub participation totals. scope = scope.where(id: params[:registrant_ids].to_s.split("-")) if params[:registrant_ids].present? - scope = scope.search_by_params({ contact_info: params[:contact_info] }) if params[:contact_info].present? + scope = scope.search_name_email(params[:name_email]) if params[:name_email].present? + # The prefixed contact search the people index uses (ContactSearchable), over + # the person's own live addresses and phones. + scope = scope.search_contact(params[:contact_info]) if params[:contact_info].present? scope = scope.where(id: person_sector_ids(params[:sector])) if params[:sector].present? scope = scope.where(id: person_affiliation_status_ids(params[:affiliation_status])) if params[:affiliation_status].present? # Breakdown drill-in filters (set when a chart row is clicked). @@ -871,8 +874,12 @@ def filtered_attendees # one pass, so they all describe the same address: someone working in two states # can't match a city from one and the county from the other. A State that # contradicts the county's own state matches nobody. + # The typed box first, the dropdowns over the top of it: a control someone can + # see wins over the same field typed into the box. + controls = { state: params[:state], county: params[:county], + country: params[:country], city: params[:city] }.compact_blank location_filters = PersonLocations.filters_from( - state: params[:state], county: params[:county], city: params[:city], country: params[:country] + **PersonLocations.parse_query(params[:work_location]).merge(controls) ) if location_filters.nil? scope = scope.none diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 13da23da18..84782b38c9 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -33,7 +33,6 @@ def index filtered = base_scope.search_by_params(filters) 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 @@ -54,7 +53,6 @@ def email_addresses people = Person.includes(:user).search_by_params(filters) 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/helpers/events_helper.rb b/app/helpers/events_helper.rb index 479880bfd7..3a6f63592d 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -239,7 +239,7 @@ def time_period_to_event_year(time_period) REPORT_SUBNAV_PARAMS = %i[ event_id event_type search funder_sgid time_period view registrant_ids attendance_status payment_status funder scholarship - sector contact_info affiliation_status organization_id org_city state county + sector name_email contact_info work_location affiliation_status organization_id org_city state county age_group life_experience country school_district program_status ].freeze diff --git a/app/models/concerns/contact_searchable.rb b/app/models/concerns/contact_searchable.rb new file mode 100644 index 0000000000..16bc83f55b --- /dev/null +++ b/app/models/concerns/contact_searchable.rb @@ -0,0 +1,105 @@ +# The prefixed search behind every "find them by where they are" box — the people +# index, the attendees index, the organizations index. Typing `city: austin +# state: tx` narrows by each field; a bare term searches all of them. Defined once +# so a prefix reaches every box at the same moment and the help text beside them +# can't drift from what the search accepts. +# +# Prefixes are the vocabulary admins learn, so they're named for what someone +# would type rather than for the column: remap a renamed column here instead of +# retraining everyone. +module ContactSearchable + extend ActiveSupport::Concern + + # "amy--aisha" is how this app says "either" everywhere else — tag filters, the + # registrants roster, the bulk-email picker, workshop search. SearchCop spells + # it `or`, so a query typed with `--` is rewritten before it gets there and the + # one hint below is true of every search box in the app. + ALTERNATIVE_SEPARATOR = "--".freeze + + # prefix => the column(s) it searches, in SearchCop's "table.column" form. + # Deliberately wider than the location charts: street and phone don't group into + # a chart, but they're exactly what someone has in hand when they're looking + # someone up. + CONTACT_SEARCH_FIELDS = { + phone: [ "contact_methods.value", "addresses.phone" ], + street: "addresses.street_address", + city: "addresses.city", + state: "addresses.state", + zip: "addresses.zip_code", + county: "addresses.county", + country: "addresses.country", + locality: "addresses.locality", + district: "addresses.district", + la_council: "addresses.la_city_council_district", + la_supervisorial: "addresses.la_supervisorial_district", + la_spa: "addresses.la_service_planning_area", + # Narrows to one kind of address (work / personal / mailing). SearchCop ANDs + # the prefixes onto the same joined row, so `type: personal city: austin` asks + # for an address that is BOTH — the way to search a personal city alone. + type: "addresses.address_type" + }.freeze + + # Prefixes a bare term doesn't search. `type:` because "personal" is a word + # people live on streets called, not a search for every personal address; the LA + # districts because they're integer columns, so a bare term asking them for a + # LIKE match would be asking a number to contain a word. + SCOPING_PREFIXES = %i[ type la_council la_supervisorial la_spa ].freeze + + # The one help hint, shared by every search box so none of them can describe a + # syntax the others don't take. + SEARCH_SYNTAX_HINT = "Several words all have to match. Use -- for either (amy--aisha), quotes for a phrase.".freeze + CONTACT_SEARCH_HINT = [ + "Prefixes: #{CONTACT_SEARCH_FIELDS.keys.map { |prefix| "#{prefix}:" }.join(', ')}.", + "Several narrow to one address (type: personal city: austin).", + SEARCH_SYNTAX_HINT + ].join(" ").freeze + + # The alternatives someone typed. A lone hyphenated value ("Smith-Jones") is one + # alternative, not two — only the doubled separator splits. + def self.alternatives(query) + query.to_s.split(ALTERNATIVE_SEPARATOR).map(&:strip).compact_blank + end + + # The same query in SearchCop's own grammar: each alternative parenthesised so + # its words still AND together under the `or`, and commas spaced out so a pasted + # "Austin, TX" reads as two words rather than one word ending in a comma. A + # quoted query is left alone — the commas in it are part of the phrase. + def self.expand_alternatives(query) + parts = alternatives(query) + parts = parts.map { |part| part.tr(",", " ").squeeze(" ").strip } unless query.to_s.include?('"') + return parts.first.to_s if parts.one? + + parts.map { |part| "(#{part})" }.join(" or ") + end + + class_methods do + # Defines `search_contact` over the CONTACT_SEARCH_FIELDS that live on the + # associations named here, so a model without one (an organization has + # addresses but no contact methods) offers the prefixes it can answer. + def searches_contact(*associations) + tables = associations.map { |association| reflect_on_association(association).table_name } + fields = CONTACT_SEARCH_FIELDS.filter_map do |prefix, columns| + reachable = Array(columns).select { |column| tables.include?(column.split(".").first) } + [ prefix, reachable ] if reachable.any? + end.to_h + searched = fields.except(*SCOPING_PREFIXES).values.flatten + + search_scope :contact_matches do + scope { left_joins(*associations) } + fields.each { |prefix, columns| attributes prefix => columns } + attributes all: searched + # Several words narrow rather than widen — typing more should find less, + # the way every search box people already use behaves. `--` says "either". + options :all, type: :text, default: true, default_operator: :and + end + + # Distinct here rather than at each call site: the joins mean an owner with + # two matching addresses would otherwise arrive twice, and a caller that + # forgets only finds out when a duplicate row reaches a page. + scope :search_contact, ->(query) { + expanded = ContactSearchable.expand_alternatives(query) + expanded.blank? ? all : contact_matches(expanded).distinct + } + end + end +end diff --git a/app/models/event_registration.rb b/app/models/event_registration.rb index ef85353fa4..9809739688 100644 --- a/app/models/event_registration.rb +++ b/app/models/event_registration.rb @@ -511,26 +511,34 @@ def self.org_name_answer_person_ids(event) else all end } + KEYWORD_MATCH_SQL = <<~SQL.freeze + LOWER(people.first_name) LIKE :term + OR LOWER(people.legal_first_name) LIKE :term + OR LOWER(people.last_name) LIKE :term + OR LOWER(CONCAT(people.first_name, ' ', people.last_name)) LIKE :term + OR LOWER(CONCAT(people.legal_first_name, ' ', people.last_name)) LIKE :term + OR LOWER(users.email) LIKE :term + OR LOWER(people.email) LIKE :term + OR LOWER(contact_methods.value) LIKE :term + OR LOWER(addresses.city) LIKE :term + OR LOWER(addresses.phone) LIKE :term + OR LOWER(organizations.name) LIKE :term + SQL + # The grammar every search box in the app shares (ContactSearchable): the words + # inside an alternative all have to match, and "amy--aisha" keeps either. Each + # word asks the whole OR block above, so "jones amy" finds Amy Jones the same as + # "amy jones" — the CONCAT rows are what used to carry that, for one word order. scope :keyword, ->(term) { - return none if term.blank? + alternatives = ContactSearchable.alternatives(term).map { |value| value.split(/\s+/) } + next none if alternatives.empty? - sanitized = "%#{sanitize_sql_like(term.downcase.strip)}%" - left_joins(registrant: [ :user, :contact_methods, :addresses, { affiliations: [ :organization ] } ]) + joined = left_joins(registrant: [ :user, :contact_methods, :addresses, { affiliations: [ :organization ] } ]) .left_joins(registrant: { affiliations: { organization: :addresses } }) - .where( - "LOWER(people.first_name) LIKE :term - OR LOWER(people.legal_first_name) LIKE :term - OR LOWER(people.last_name) LIKE :term - OR LOWER(CONCAT(people.first_name, ' ', people.last_name)) LIKE :term - OR LOWER(CONCAT(people.legal_first_name, ' ', people.last_name)) LIKE :term - OR LOWER(users.email) LIKE :term - OR LOWER(people.email) LIKE :term - OR LOWER(contact_methods.value) LIKE :term - OR LOWER(addresses.city) LIKE :term - OR LOWER(addresses.phone) LIKE :term - OR LOWER(organizations.name) LIKE :term", - term: sanitized - ) + alternatives + .map { |words| + words.reduce(joined) { |scope, word| scope.where(KEYWORD_MATCH_SQL, term: "%#{sanitize_sql_like(word.downcase)}%") } + } + .reduce(:or) .distinct } diff --git a/app/models/organization.rb b/app/models/organization.rb index c0cbb49d43..e00f5281a8 100644 --- a/app/models/organization.rb +++ b/app/models/organization.rb @@ -15,6 +15,7 @@ class Organization < ApplicationRecord belongs_to :parent, class_name: "Organization", optional: true, inverse_of: :children has_many :children, class_name: "Organization", foreign_key: :parent_id, inverse_of: :parent, dependent: :nullify has_many :addresses, as: :addressable, dependent: :destroy + has_many :active_addresses, -> { active }, as: :addressable, class_name: "Address", inverse_of: :addressable has_many :bookmarks, as: :bookmarkable, dependent: :destroy has_many :other_responses, as: :owner, dependent: :destroy has_many :invoices, as: :invoicee, dependent: :restrict_with_error @@ -86,6 +87,9 @@ def communications_email attributes :name end + include ContactSearchable + searches_contact :active_addresses + # Scopes # See TagFilterable, Trendable, WindowsTypeFilterable # An org is active because someone is affiliated there, not because the legacy @@ -93,31 +97,6 @@ def communications_email scope :active, -> { where(id: Affiliation.active.select(:organization_id)) } scope :name_like, ->(name) { where("organizations.name LIKE ?", "%#{sanitize_sql_like(name)}%") } scope :with_active_facilitators, -> { where(id: Affiliation.active.facilitators.select(:organization_id)) } - scope :address, ->(address) do - return all if address.blank? - terms = address.to_s.strip.split(/[\s,]+/).reject(&:blank?) - return all if terms.empty? - - scope = joins(:addresses) - terms.each do |term| - wildcard = "%#{sanitize_sql_like(term)}%" - scope = scope.where(<<~SQL.squish, wildcard: wildcard) - addresses.street_address LIKE :wildcard OR - addresses.city LIKE :wildcard OR - addresses.state LIKE :wildcard OR - addresses.county LIKE :wildcard OR - addresses.country LIKE :wildcard OR - addresses.district LIKE :wildcard OR - addresses.locality LIKE :wildcard OR - addresses.phone LIKE :wildcard OR - addresses.zip_code LIKE :wildcard OR - CAST(addresses.la_city_council_district AS CHAR) LIKE :wildcard OR - CAST(addresses.la_service_planning_area AS CHAR) LIKE :wildcard OR - CAST(addresses.la_supervisorial_district AS CHAR) LIKE :wildcard - SQL - end - scope.distinct - end # Off facilitator affiliations only, never the legacy organization_status, so the # filter and the status chip can't disagree (ADR-0001 D3). scope :program_status, ->(bucket) { @@ -228,7 +207,7 @@ def self.search_by_params(params) organizations = organizations.search(params[:query]) if params[:query].present? organizations = organizations.sector_names_all(params[:sector_names_all].presence || params[:sector_name]) organizations = organizations.category_names_all(params[:category_names_all].presence || params[:age_group_name]) - organizations = organizations.address(params[:address]) if params[:address].present? + organizations = organizations.search_contact(params[:address]) if params[:address].present? organizations = organizations.organization_ids(params[:organization_ids]) if params[:organization_ids].present? organizations = organizations.program_status(params[:program_status]) if params[:program_status].present? organizations diff --git a/app/models/person.rb b/app/models/person.rb index 3952d3f1e2..c91f7f532c 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -25,6 +25,9 @@ class Person < ApplicationRecord has_many :comments, -> { newest_first }, as: :commentable, dependent: :destroy has_many :contact_methods, as: :contactable, dependent: :destroy has_many :active_phones, -> { phone.where(inactive: false) }, as: :contactable, class_name: "ContactMethod" + # Searching someone up reads their live addresses only: a retired one is still + # the right place to SHOW them (Address::STANDING) but a wrong place to find them. + has_many :active_addresses, -> { active }, as: :addressable, class_name: "Address", inverse_of: :addressable has_many :categorizable_items, inverse_of: :categorizable, as: :categorizable, dependent: :destroy has_many :sectorable_items, as: :sectorable, dependent: :destroy has_many :other_responses, as: :owner, dependent: :destroy @@ -208,26 +211,18 @@ 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 + search_scope :name_email_matches 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 + # "amy jones" means the one person, not everyone called either. See + # ContactSearchable for the shared grammar. + options :all, type: :text, default: true, default_operator: :and end + # Takes the app's `--` for "either", the same as the Contact info box beside it. + scope :search_name_email, ->(query) { name_email_matches(ContactSearchable.expand_alternatives(query)) } - search_scope :search_contact do - scope { left_joins(:contact_methods, :addresses) } - 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.county", "addresses.country" ] - options :all, type: :text, default: true, default_operator: :or - end + include ContactSearchable + searches_contact :contact_methods, :active_addresses FIRST_NAME_HIDDEN_PREFERENCES = %w[last_name_only].freeze LAST_NAME_HIDDEN_PREFERENCES = %w[first_name_last_initial first_name_only].freeze @@ -447,7 +442,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.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/services/attendees_active_filters.rb b/app/services/attendees_active_filters.rb index df5982ef52..c74e22034c 100644 --- a/app/services/attendees_active_filters.rb +++ b/app/services/attendees_active_filters.rb @@ -3,7 +3,8 @@ # (they arrive from a chart-row click, the participation summary, or a shared # link). The index surfaces each as a removable chip so an admin can see and # clear a filter that would otherwise silently shrink the list. Params that -# already have their own visible control in events/_attendees_search (contact_info, +# already have their own visible control in events/_attendees_search (name_email, +# contact_info, # event, year, sector, program status, affiliation status, and the registration-level # filters shared with the roster — payment method, scholarship, funder name, CE # status, user account, topic subscription, city, state, county, comment status and @@ -15,7 +16,7 @@ class AttendeesActiveFilters # honours but this list omits narrows the page silently AND is dropped by the # filter form's hidden fields on the next change, since both read this list. CHIP_PARAMS = %w[ - registrant_ids organization_id org_city age_group life_experience + registrant_ids organization_id org_city city age_group life_experience country school_district payment_status funder ].freeze # The roster has no filter form at all, so every drill-in it accepts needs a @@ -47,6 +48,9 @@ def label_for(param, value) when "sector" then record_label("Sector", Sector, value) when "organization_id" then record_label("Organization", Organization, value) when "org_city" then "City: #{value}" + # The City box moved into the prefixed "Where they work" box, so a bare city + # param now arrives from a link or an older bookmark with nothing to show it. + when "city" then "City: #{value}" when "age_group" then record_label("Age group", Category, value) when "life_experience" then record_label("Life experience", Category, value) when "country" then "Country: #{value}" diff --git a/app/services/person_locations.rb b/app/services/person_locations.rb index 57379ccf24..9e2c392572 100644 --- a/app/services/person_locations.rb +++ b/app/services/person_locations.rb @@ -14,11 +14,28 @@ class PersonLocations city county state country locality zip_code district la_city_council_district la_supervisorial_district la_service_planning_area ].freeze - # Fields a filter matches on a case-insensitive substring, because they arrive - # from a free-text box. The rest are picked from a list this class built, so + # Fields a filter matches on a case-insensitive substring, because someone types + # them rather than picking them. The rest come from a list this class built, so # they match whole (case-insensitively, as the database's own collation does). - # Add a field here when it gets a text box of its own. - SEARCHED_FIELDS = %i[ city ].freeze + SEARCHED_FIELDS = %i[ city locality district zip_code ].freeze + # The prefix someone types for each field. Named for the word rather than the + # column, the same bargain ContactSearchable strikes — and matching it, so the + # two boxes on the attendees page read alike even though they answer about + # different addresses. + FIELD_PREFIXES = { + city: :city, county: :county, state: :state, country: :country, + locality: :locality, zip: :zip_code, district: :district, + la_council: :la_city_council_district, + la_supervisorial: :la_supervisorial_district, + la_spa: :la_service_planning_area + }.freeze + # A term with no prefix, which has to match SOMEWHERE on the row. Not a column, + # so #matching handles it apart from the rest. + ANY_FIELD = :any + # `city: austin`, `locality: "Outside CA"`, or a bare term. + QUERY_TOKEN = /(?:(\w+):\s*)?("[^"]*"|\S+)/ + SEARCH_HINT = "Prefixes: #{FIELD_PREFIXES.keys.map { |prefix| "#{prefix}:" }.join(', ')}. " \ + "All of them have to describe one address. #{ContactSearchable::SEARCH_SYNTAX_HINT}".freeze # County filter options carry their state so same-named counties across states # don't collide — "CA|Los Angeles" on the registrants roster, "CA::Los Angeles" # on the attendees index. @@ -47,6 +64,19 @@ def self.split_county(value) county.nil? ? [ nil, state ] : [ state, county ] end + # A typed query ("city: austin locality: \"Outside CA\"") as fields #matching + # understands. An unrecognised prefix and a bare term both become ANY_FIELD + # terms rather than an error — someone mistyping `citty:` should see too much, + # not an empty page they can't explain. + def self.parse_query(query) + query.to_s.scan(QUERY_TOKEN).each_with_object({}) do |(prefix, value), filters| + field = FIELD_PREFIXES[prefix&.downcase&.to_sym] + value = value.delete_prefix('"').delete_suffix('"') + next filters[field] = value if field + filters[ANY_FIELD] = [ *filters[ANY_FIELD], prefix ? "#{prefix}:#{value}" : value ] + end + end + # Takes the people to locate, as a relation (kept as a subquery, so a # whole-population lookup never inlines every id) or as an explicit id array. def initialize(people, on: nil) @@ -75,8 +105,12 @@ def rows # the whole filter set in one call; chaining two calls is what loses that. def matching(**filters) filters = filters.compact_blank - filters.assert_valid_keys(*LOCATION_FIELDS) - rows.select { |row| filters.all? { |field, value| matches?(row, field, value) } } + filters.assert_valid_keys(*LOCATION_FIELDS, ANY_FIELD) + any_terms = Array(filters.delete(ANY_FIELD)) + rows.select do |row| + filters.all? { |field, value| matches?(row, field, value) } && + any_terms.all? { |term| LOCATION_FIELDS.any? { |field| matches?(row, field, term, searched: true) } } + end end # Distinct [ state, county ] pairs, sorted, for the County filters. @@ -114,9 +148,9 @@ def candidate_values(*fields) private - def matches?(row, field, value) + def matches?(row, field, value, searched: SEARCHED_FIELDS.include?(field)) actual = row.public_send(field).to_s - return actual.downcase.include?(value.to_s.downcase.strip) if SEARCHED_FIELDS.include?(field) + return actual.downcase.include?(value.to_s.downcase.strip) if searched actual.casecmp?(value.to_s) end diff --git a/app/services/reminder_recipient_filter.rb b/app/services/reminder_recipient_filter.rb index 68fced6467..05b6b8cbf4 100644 --- a/app/services/reminder_recipient_filter.rb +++ b/app/services/reminder_recipient_filter.rb @@ -162,17 +162,18 @@ def registrant_emails(reg) .compact_blank.map(&:downcase) end - # Text filters accept several values separated by "--" and match a registrant - # when ANY of them hits (e.g. "amy--aisha" keeps both Amy and Aisha). Single - # hyphens inside a value are preserved. Returns true when no term was typed. + # The grammar every search box in the app shares (ContactSearchable): the words + # inside an alternative all have to match, and "--" keeps either (e.g. + # "amy--aisha" keeps both Amy and Aisha). Single hyphens inside a value are + # preserved. Returns true when no term was typed. def any_term?(key) tokens = terms_for(key) return true if tokens.empty? - tokens.any? { |token| yield token } + tokens.any? { |token| token.split(/\s+/).all? { |word| yield word } } end def terms_for(key) - @params[key].to_s.downcase.split("--").map(&:strip).reject(&:blank?) + ContactSearchable.alternatives(@params[key].to_s.downcase) end def all_registration_ids diff --git a/app/views/events/_attendees_search.html.erb b/app/views/events/_attendees_search.html.erb index a08fd1d49d..6e5c2205ea 100644 --- a/app/views/events/_attendees_search.html.erb +++ b/app/views/events/_attendees_search.html.erb @@ -8,7 +8,7 @@ <% field_class = search_field_class %> <%# Open the drawer on load when any collapsed filter already carries a value, so an active filter is never hidden behind the toggle. %> -<% collapsed_keys = %i[event_id event_year sector program_status affiliation_status payment_method scholarship funder_name ce_status account_status topic_subscription city state county comment_status comment] %> +<% collapsed_keys = %i[event_id event_year sector program_status affiliation_status payment_method scholarship funder_name ce_status account_status topic_subscription contact_info work_location city state county comment_status comment] %> <% more_active = collapsed_keys.any? { |k| params[k].present? } %> <%# Keep drill-in filters applied while the admin tweaks a visible select — the @@ -27,8 +27,9 @@ <% chips = render "events/attendees_chips" %> <% primary = capture do %> - <%= render "shared/filter_text", param: :contact_info, label: "Name, email, or phone", - field_class: field_class, submit: true, wrapper_class: "w-full md:flex-1 md:min-w-[14rem]" %> + <%= render "shared/filter_text", param: :name_email, label: "Name or email", + field_class: field_class, submit: true, tooltip: ContactSearchable::SEARCH_SYNTAX_HINT, + wrapper_class: "w-full md:flex-1 md:min-w-[14rem]" %> <%# Attendance and event type default to "attended" / "trainings" — the population this page has always shown — so the neutral "All …" is the first option rather @@ -102,8 +103,24 @@ selected: Array(params[:topic_subscription]).map(&:to_i), placeholder: "Any topic" %> <% end %> - <%= render "shared/filter_text", param: :city, label: "City", - placeholder: "City name", field_class: field_class %> + <%# The same prefixed box as the people index (ContactSearchable), admin-only + there and here. It reads the person's OWN contact record, while the City / + State / County filters below read where they work on these registrations — + so the two answer different questions and the labels say which. %> + <% if allowed_to?(:manage?, Person) %> + <%= render "shared/filter_text", param: :contact_info, label: "Their own contact info", submit: true, + placeholder: "city: austin", field_class: field_class, + tooltip: ContactSearchable::CONTACT_SEARCH_HINT %> + <% end %> + + <%# Where someone WORKS on the filtered event's date (PersonLocations), as + against the contact box above, which reads their own address. Prefixed the + same way so the two read alike, and one box rather than a field each: every + prefix has to describe the same address, which only holds if they arrive + together. %> + <%= render "shared/filter_text", param: :work_location, label: "Where they work", submit: true, + placeholder: "city: austin", field_class: field_class, + tooltip: PersonLocations::SEARCH_HINT, wrapper_class: "w-full md:w-64" %> <%= render "shared/filter_select", param: :state, label: "State", options: @attendee_states, diff --git a/app/views/events/_registrant_filters.html.erb b/app/views/events/_registrant_filters.html.erb index 7efb8e770d..c7164987e0 100644 --- a/app/views/events/_registrant_filters.html.erb +++ b/app/views/events/_registrant_filters.html.erb @@ -41,6 +41,7 @@ <% primary_text.each do |f| %> <%= render "shared/filter_text", param: f[:param], label: f[:label], placeholder: f[:placeholder], field_class: field_class, label_class: label_class, submit: f[:search], + tooltip: ContactSearchable::SEARCH_SYNTAX_HINT, wrapper_class: f[:search] ? "w-full md:flex-1 md:min-w-0" : "w-full md:w-48" %> <% end %> diff --git a/app/views/people/_search_boxes.html.erb b/app/views/people/_search_boxes.html.erb index 59fca28040..df19d30880 100644 --- a/app/views/people/_search_boxes.html.erb +++ b/app/views/people/_search_boxes.html.erb @@ -9,7 +9,7 @@
<%= render "shared/filter_text", param: :name_email, label: "Name or email", submit: true, - wrapper_class: "w-full md:flex-1" %> + tooltip: ContactSearchable::SEARCH_SYNTAX_HINT, wrapper_class: "w-full md:flex-1" %> <%= render "shared/filter_text", param: :organization_name, label: "Organization", submit: true, @@ -74,7 +74,7 @@ <%= render "shared/filter_text", param: :contact_info, label: "Contact info", submit: true, wrapper_class: "w-full", - tooltip: "Prefixes: phone:, street:, city:, state:, zip:, county:, country:" %> + tooltip: ContactSearchable::CONTACT_SEARCH_HINT %>
<% end %> diff --git a/db/seeds/features/2026-10-25-one-search-syntax-everywhere.yml b/db/seeds/features/2026-10-25-one-search-syntax-everywhere.yml new file mode 100644 index 0000000000..d64f5d97e3 --- /dev/null +++ b/db/seeds/features/2026-10-25-one-search-syntax-everywhere.yml @@ -0,0 +1,19 @@ +name: "One search syntax across every filter box" +area: reporting +display_status: admin_facing +released_on: 2026-10-25 +pr_number: 2844 +summary: >- + Every search box now works the same way: several words all have to match, "--" + means either, and quotes make a phrase. The Contact info boxes on people and + attendees, and the Address box on organizations, take prefixes like "city:" and + "county:". +pro_tips: + - "Several words narrow: amy jones finds the one person, not everyone called Amy plus everyone called Jones." + - "amy--aisha finds both. That works in every box, including the roster keyword search and the bulk email picker." + - "The two rules combine: amy jones--mary davis finds Amy Jones and Mary Davis, and nobody called Amy Davis." + - "Word order does not matter: jones amy finds Amy Jones." + - "Prefixes: phone:, street:, city:, state:, zip:, county:, country:, locality:, district:, la_council:, la_supervisorial:, la_spa:, and type: for work or personal." + - "type: personal city: austin asks for one address that is BOTH personal and in Austin — that is how to search a personal city on its own." + - "Contact info searches someone's own live addresses. Where they work searches the organization address on their registrations. The two can disagree, on purpose." + - "Hover the little i beside any search box to see what it takes." diff --git a/db/seeds/features/2026-10-25-prefixed-contact-search-on-attendees.yml b/db/seeds/features/2026-10-25-prefixed-contact-search-on-attendees.yml new file mode 100644 index 0000000000..13972b5561 --- /dev/null +++ b/db/seeds/features/2026-10-25-prefixed-contact-search-on-attendees.yml @@ -0,0 +1,15 @@ +name: "Prefixed contact search on the attendees page" +area: reporting +display_status: admin_facing +released_on: 2026-10-25 +pr_number: 2844 +summary: >- + The attendees page now has the same admin-only "Contact info" box as the people + index, where you can type a prefix to search one part of someone's address — + "city: austin", "county: travis", "zip: 78701". Its main search box is now name + or email, matching the people index. +pro_tips: + - "Prefixes: phone:, street:, city:, state:, zip:, county:, country:, locality:, district:. Type several and each narrows the list further." + - "With no prefix, the term is searched across all of those fields at once." + - "This box reads the person's OWN address, while the City, State and County filters next to it read where they work on these registrations — so the two can disagree, on purpose." + - "Both boxes are super-admin only; nobody else sees them." diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb index 374dfd477e..ac4791c451 100644 --- a/spec/models/organization_spec.rb +++ b/spec/models/organization_spec.rb @@ -172,7 +172,7 @@ end end - describe '.address' do + describe '.search_contact' do let!(:status) { create(:organization_status, name: "Active") } let!(:org_la) do @@ -194,42 +194,42 @@ let!(:org_no_address) { create(:organization, name: "No Address Org", organization_status: status) } it 'returns all organizations when address is blank' do - expect(Organization.address("")).to include(org_la, org_ny, org_no_address) - expect(Organization.address(nil)).to include(org_la, org_ny, org_no_address) + expect(Organization.search_contact("")).to include(org_la, org_ny, org_no_address) + expect(Organization.search_contact(nil)).to include(org_la, org_ny, org_no_address) end it 'finds organization by city name' do - results = Organization.address("Los Angeles") + results = Organization.search_contact("Los Angeles") expect(results).to include(org_la) expect(results).not_to include(org_ny, org_no_address) end it 'finds organization by zip code' do - results = Organization.address("90001") + results = Organization.search_contact("90001") expect(results).to include(org_la) expect(results).not_to include(org_ny) end it 'finds organization by state abbreviation' do - results = Organization.address("CA") + results = Organization.search_contact("CA") expect(results).to include(org_la) expect(results).not_to include(org_ny) end it 'finds organization by partial street address' do - results = Organization.address("123 Main") + results = Organization.search_contact("123 Main") expect(results).to include(org_la) expect(results).not_to include(org_ny) end it 'finds organization by full address with commas' do - results = Organization.address("123 Main St, Los Angeles, CA 90001") + results = Organization.search_contact("123 Main St, Los Angeles, CA 90001") expect(results).to include(org_la) expect(results).not_to include(org_ny) end it 'finds organization by city and state' do - results = Organization.address("Los Angeles, CA") + results = Organization.search_contact("Los Angeles, CA") expect(results).to include(org_la) expect(results).not_to include(org_ny) end @@ -237,18 +237,18 @@ it 'does not return duplicates when org has multiple matching addresses' do create(:address, addressable: org_la, street_address: "789 Oak Ave", city: "Los Angeles", state: "CA", zip_code: "90002", locality: "LA City") - results = Organization.address("Los Angeles") + results = Organization.search_contact("Los Angeles") expect(results.to_a.count { |o| o.id == org_la.id }).to eq(1) end it 'does not return organizations without addresses' do - results = Organization.address("Los Angeles") + results = Organization.search_contact("Los Angeles") expect(results).not_to include(org_no_address) end it 'finds organization by phone number' do org_la.addresses.first.update!(phone: "213-555-1234") - results = Organization.address("213-555-1234") + results = Organization.search_contact("213-555-1234") expect(results).to include(org_la) expect(results).not_to include(org_ny) end diff --git a/spec/models/person_spec.rb b/spec/models/person_spec.rb index acfac6717a..da967f001f 100644 --- a/spec/models/person_spec.rb +++ b/spec/models/person_spec.rb @@ -680,22 +680,22 @@ def phone_numbers expect(results).to include(person_alice, person_bob) end - it 'filters by contact_info matching name' do - results = Person.search_by_params(contact_info: 'Alice') + it 'filters by name_email matching name' do + results = Person.search_name_email('Alice') expect(results).to include(person_alice) expect(results).not_to include(person_bob) end - it 'filters by contact_info matching legal first name' do + it 'filters by name_email matching legal first name' do person_carol = create(:person, first_name: 'Carol', legal_first_name: 'Caroline', last_name: 'White') - results = Person.search_by_params(contact_info: 'Caroline') + results = Person.search_name_email('Caroline') expect(results).to include(person_carol) expect(results).not_to include(person_alice, person_bob) end - it 'filters by contact_info matching email' do - results = Person.search_by_params(contact_info: 'bob@example') + it 'filters by name_email matching email' do + results = Person.search_name_email('bob@example') expect(results).to include(person_bob) expect(results).not_to include(person_alice) end @@ -774,24 +774,24 @@ def phone_numbers expect(Person.search_by_params(shared_contact_info: 'Smith')).to include(person_alice) end - it 'filters by contact_info matching an email the person hides' do + it 'filters by name_email matching an email the person hides' do person_alice.update!(email: 'alice@example.com', profile_show_email: false) - expect(Person.search_by_params(contact_info: 'alice@example')).to include(person_alice) + expect(Person.search_name_email('alice@example')).to include(person_alice) end %w[first_name_last_initial first_name_only].each do |preference| - it "filters by contact_info matching the last name when the display preference is #{preference}" do + it "filters by name_email matching the last name when the display preference is #{preference}" do person_alice.update!(display_name_preference: preference) - expect(Person.search_by_params(contact_info: 'Smith')).to include(person_alice) + expect(Person.search_name_email('Smith')).to include(person_alice) end end - it 'filters by contact_info matching the first name when the display preference is last_name_only' do + it 'filters by name_email matching the first name when the display preference is last_name_only' do person_alice.update!(display_name_preference: 'last_name_only', profile_show_email: false) - expect(Person.search_by_params(contact_info: 'Alice')).to include(person_alice) + expect(Person.search_name_email('Alice')).to include(person_alice) end it 'chains shared_contact_info and organization_name' do @@ -806,8 +806,8 @@ def phone_numbers expect(results).not_to include(person_bob) end - it 'chains contact_info and organization_name' do - results = Person.search_by_params(contact_info: 'Alice', organization_name: 'Alpha') + it 'chains the name search onto a search_by_params filter, the way the index does' do + results = Person.search_by_params(organization_name: 'Alpha').search_name_email('Alice') expect(results).to include(person_alice) expect(results).not_to include(person_bob) end diff --git a/spec/requests/events/attendees_spec.rb b/spec/requests/events/attendees_spec.rb index d28f91c93f..fd61defd44 100644 --- a/spec/requests/events/attendees_spec.rb +++ b/spec/requests/events/attendees_spec.rb @@ -62,6 +62,14 @@ expect(response.body).to include(dashboard_event_path(owned_training)) end + # The prefixed contact box reaches a person's own address, so it stays behind + # the same super-admin gate it sits behind on the people index. + it "does not offer the contact-info search" do + get attendees_events_url(event_id: owned_training.id) + expect(response.body).to include('name="name_email"') + expect(response.body).not_to include('name="contact_info"') + end + it "forbids an event they do not own" do get attendees_events_url(event_id: recent_training.id) expect(response).to redirect_to(root_path) @@ -288,11 +296,28 @@ it "filters by name search" do create(:event_registration, event: recent_training, registrant: create(:person, first_name: "Katherine", last_name: "Johnson"), status: "attended") - get attendees_events_url(contact_info: "Lovelace"), headers: frame_headers + get attendees_events_url(name_email: "Lovelace"), headers: frame_headers expect(response.body).to include("Ada Lovelace") expect(response.body).not_to include("Katherine Johnson") end + # The same prefixed box as the people index, over the person's own contact + # record — so "city: boise" here means where Ada lives, not where she works. + it "filters by the prefixed contact search" do + create(:address, addressable: attendee, city: "Boise", state: "ID", county: "Ada", inactive: false) + other = create(:person, first_name: "Katherine", last_name: "Johnson") + create(:address, addressable: other, city: "Portland", state: "OR", inactive: false) + create(:event_registration, event: recent_training, registrant: other, status: "attended") + + get attendees_events_url(contact_info: "city: boise"), headers: frame_headers + expect(response.body).to include("Ada Lovelace") + expect(response.body).not_to include("Katherine Johnson") + + get attendees_events_url(contact_info: "state: OR"), headers: frame_headers + expect(response.body).not_to include("Ada Lovelace") + expect(response.body).to include("Katherine Johnson") + end + it "shows the Program status and Affiliation status columns" do get attendees_events_url, headers: frame_headers expect(response.body).to include("Program status") @@ -587,6 +612,39 @@ expect(response.body).not_to include("Ada Lovelace") end + # One box, prefixed like the contact box above it, so every part still has + # to describe the same work address. + it "filters by the prefixed Where they work box" do + org = create(:organization) + create(:address, addressable: org, city: "Austin", state: "TX", county: "Travis", + zip_code: "78701", locality: Address::OUTSIDE_CA, inactive: false) + attendee_registration.event_registration_organizations.create!(organization: org) + other = create(:person, first_name: "Katherine", last_name: "Johnson") + other_registration = create(:event_registration, event: recent_training, registrant: other, status: "attended") + other_org = create(:organization) + create(:address, addressable: other_org, city: "Portland", state: "OR", county: "Multnomah", inactive: false) + other_registration.event_registration_organizations.create!(organization: other_org) + + get attendees_events_url(work_location: "city: austin"), headers: frame_headers + expect(response.body).to include("Ada Lovelace") + expect(response.body).not_to include("Katherine Johnson") + + get attendees_events_url(work_location: 'locality: "Outside CA" zip: 78701'), headers: frame_headers + expect(response.body).to include("Ada Lovelace") + + get attendees_events_url(work_location: "city: austin county: Multnomah"), headers: frame_headers + expect(response.body).not_to include("Ada Lovelace") + end + + it "lets the State dropdown win over the same field typed in the box" do + org = create(:organization) + create(:address, addressable: org, city: "Austin", state: "TX", inactive: false) + attendee_registration.event_registration_organizations.create!(organization: org) + + get attendees_events_url(work_location: "state: OR city: austin", state: "TX"), headers: frame_headers + expect(response.body).to include("Ada Lovelace") + end + it "matches nobody when the State dropdown contradicts the county's own state" do org = create(:organization) create(:address, addressable: org, city: "Portland", state: "OR", county: "Multnomah", inactive: false) @@ -653,8 +711,11 @@ EventsController::ATTENDEE_REGISTRATION_FILTERS.each_key do |param| expect(page).to have_selector("##{param}", visible: :all), "no control for #{param}" end - expect(page).to have_selector("#city", visible: :all) + expect(page).to have_selector("#work_location", visible: :all) expect(page).to have_selector("#topic_subscription option[value='#{topic.id}']", visible: :all) + # city lost its own box to the prefixed work_location one, so it rides as + # a chip param instead — still sent, still removable, never silent. + expect(AttendeesActiveFilters::CHIP_PARAMS).to include("city") end it "keeps the registration-level filter selections" do diff --git a/spec/requests/events_spec.rb b/spec/requests/events_spec.rb index b23aff9a9b..d9892b2425 100644 --- a/spec/requests/events_spec.rb +++ b/spec/requests/events_spec.rb @@ -1779,6 +1779,43 @@ def pay_for_others_classes(template) expect(response.body).to include("Any status") end + # The roster keyword box speaks the grammar every other box does + # (ContactSearchable): words AND, "--" is either. + it "narrows the roster by either of two keywords" do + amy = create(:person, first_name: "Amykeyword", last_name: "One") + aisha = create(:person, first_name: "Aishakeyword", last_name: "Two") + sam = create(:person, first_name: "Samkeyword", last_name: "Three") + [ amy, aisha, sam ].each { |registrant| create(:event_registration, event: event, registrant: registrant) } + + get registrants_event_path(event, keyword: "amykeyword--aishakeyword") + + expect(response.body).to include("Amykeyword", "Aishakeyword") + expect(response.body).not_to include("Samkeyword") + end + + it "makes every word in a roster keyword match, in any order" do + match = create(:person, first_name: "Amykeyword", last_name: "Joneskeyword") + half = create(:person, first_name: "Amykeyword", last_name: "Daviskeyword") + [ match, half ].each { |registrant| create(:event_registration, event: event, registrant: registrant) } + + get registrants_event_path(event, keyword: "joneskeyword amykeyword") + + expect(response.body).to include("Joneskeyword") + expect(response.body).not_to include("Daviskeyword") + end + + it "ANDs the words inside each roster alternative and ORs the alternatives" do + amy = create(:person, first_name: "Amykeyword", last_name: "Joneskeyword") + mary = create(:person, first_name: "Marykeyword", last_name: "Daviskeyword") + mixed = create(:person, first_name: "Amykeyword", last_name: "Daviskeyword") + [ amy, mary, mixed ].each { |registrant| create(:event_registration, event: event, registrant: registrant) } + + get registrants_event_path(event, keyword: "amykeyword joneskeyword--marykeyword daviskeyword") + + expect(response.body).to include("Joneskeyword", "Marykeyword") + expect(response.body).not_to include("Amykeyword Daviskeyword") + end + it "narrows the roster by work city" do here = create(:person, first_name: "Cityful", last_name: "Local") there = create(:person, first_name: "Faraway", last_name: "Person") diff --git a/spec/requests/people_search_spec.rb b/spec/requests/people_search_spec.rb index 78786ce669..1a3ff865d2 100644 --- a/spec/requests/people_search_spec.rb +++ b/spec/requests/people_search_spec.rb @@ -379,6 +379,46 @@ def search_contact_details(term) expect(search_contact_details("Hiddenway")).to include("Hiddenfacts") end + # `type:` rides on the same joined address as the other prefixes, so it is how + # you search a personal city alone rather than any address's city. + it "narrows the contact search to one kind of address with type:" do + create(:address, addressable: person, city: "Hiddencity", address_type: "personal") + create(:address, addressable: person, city: "Workcity", address_type: "work") + + expect(search_contact_details("type: personal city: Hiddencity")).to include("Hiddenfacts") + expect(search_contact_details("type: work city: Hiddencity")).not_to include("Hiddenfacts") + expect(search_contact_details("type: work city: Workcity")).to include("Hiddenfacts") + end + + # The two rules meet here: words AND inside an alternative, alternatives OR + # across. Each side is parenthesised on the way into SearchCop to get that — + # unparenthesised, the `or` binds to the last word alone and "amy jones--mary + # davis" quietly becomes "amy AND (jones OR mary) AND davis", which matches + # nobody. + it "ANDs the words inside each -- alternative and ORs the alternatives" do + amy = create(:person, first_name: "Amyname", last_name: "Jonesname") + mary = create(:person, first_name: "Maryname", last_name: "Davisname") + mixed = create(:person, first_name: "Amyname", last_name: "Davisname") + + body = search("amyname jonesname--maryname davisname") + + expect(body).to include(amy.first_name, mary.first_name) + expect(body).to include("Jonesname", "Davisname") + expect(body).not_to include(mixed.name) + end + + # Every search box in the app takes "--" for "either"; the prefixed boxes get + # it by rewriting it into SearchCop's own `or`. + it "takes -- alternatives in both boxes" do + other = create(:person, first_name: "Otherfacts") + create(:address, addressable: other, city: "Othercity") + create(:address, addressable: person, city: "Hiddencity") + + expect(search("Hiddenfacts--Otherfacts")).to include("Hiddenfacts", "Otherfacts") + body = search_contact_details("city: Hiddencity--city: Othercity") + expect(body).to include("Hiddenfacts", "Otherfacts") + end + %w[first_name_last_initial first_name_only].each do |preference| it "matches the last name when the display preference is #{preference}" do person.update!(last_name: "Hiddensurname", display_name_preference: preference) diff --git a/spec/services/person_locations_spec.rb b/spec/services/person_locations_spec.rb index 84bdd2edd3..545a07ffc1 100644 --- a/spec/services/person_locations_spec.rb +++ b/spec/services/person_locations_spec.rb @@ -148,6 +148,45 @@ end end + describe ".parse_query" do + it "reads prefixes, quoted values and bare terms" do + expect(described_class.parse_query('city: austin state: TX')).to eq(city: "austin", state: "TX") + expect(described_class.parse_query('locality: "Outside CA"')).to eq(locality: "Outside CA") + expect(described_class.parse_query("zip: 78701 la_council: 3")).to eq(zip_code: "78701", la_city_council_district: "3") + end + + # A mistyped prefix should show too much, not an empty page nobody can explain. + it "keeps a bare term and an unknown prefix as an any-field term" do + expect(described_class.parse_query("austin")).to eq(any: [ "austin" ]) + expect(described_class.parse_query("citty: austin")).to eq(any: [ "citty:austin" ]) + end + + it "is empty for a blank query" do + expect(described_class.parse_query(nil)).to eq({}) + end + end + + describe "#matching with a bare term" do + before do + create(:address, addressable: organization, city: "Austin", state: "TX", + county: "Travis", locality: Address::OUTSIDE_CA, zip_code: "78701") + end + + it "matches any location field on the row" do + expect(locations.matching(any: [ "travis" ])).to be_present + expect(locations.matching(any: [ "787" ])).to be_present + expect(locations.matching(any: [ "Outside CA" ])).to be_present + expect(locations.matching(any: [ "boise" ])).to be_empty + end + + it "makes every term hold, alongside the prefixed ones" do + expect(locations.matching(any: [ "austin", "travis" ])).to be_present + expect(locations.matching(any: [ "austin", "boise" ])).to be_empty + expect(locations.matching(state: "TX", any: [ "travis" ])).to be_present + expect(locations.matching(state: "OR", any: [ "travis" ])).to be_empty + end + end + describe ".filters_from" do it "folds the county option's own state into the state filter" do expect(described_class.filters_from(county: "CA|Los Angeles")).to eq(state: "CA", county: "Los Angeles") diff --git a/spec/services/reminder_recipient_filter_spec.rb b/spec/services/reminder_recipient_filter_spec.rb index 0efd38f3e8..f8a2436ab9 100644 --- a/spec/services/reminder_recipient_filter_spec.rb +++ b/spec/services/reminder_recipient_filter_spec.rb @@ -44,6 +44,17 @@ def matched(params, registrations) expect(matched({ name: "amy--aisha" }, [ amy, aisha, sam ])).to eq([ amy.id, aisha.id ].to_set) end + # The grammar every box shares: words AND inside an alternative, alternatives OR. + it "makes every word match, in any order, and ORs across --" do + amy = registration(first_name: "Amy", last_name: "Jones") + mary = registration(first_name: "Mary", last_name: "Davis") + mixed = registration(first_name: "Amy", last_name: "Davis") + regs = [ amy, mary, mixed ] + + expect(matched({ name: "jones amy" }, regs)).to eq([ amy.id ].to_set) + expect(matched({ name: "amy jones--mary davis" }, regs)).to eq([ amy.id, mary.id ].to_set) + end + it "preserves single hyphens inside a name term" do hyphen = registration(first_name: "Mary", last_name: "Jane-Wells") other = registration(first_name: "Sam") From b7c1b328e463e4eadbfa48be6503ddd0e93fb653 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 13:33:53 -0400 Subject: [PATCH 2/6] Point the search feature entries at their own PR Co-Authored-By: Claude Opus 5 (1M context) --- db/seeds/features/2026-10-25-one-search-syntax-everywhere.yml | 2 +- .../2026-10-25-prefixed-contact-search-on-attendees.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/db/seeds/features/2026-10-25-one-search-syntax-everywhere.yml b/db/seeds/features/2026-10-25-one-search-syntax-everywhere.yml index d64f5d97e3..59ec56e8e2 100644 --- a/db/seeds/features/2026-10-25-one-search-syntax-everywhere.yml +++ b/db/seeds/features/2026-10-25-one-search-syntax-everywhere.yml @@ -2,7 +2,7 @@ name: "One search syntax across every filter box" area: reporting display_status: admin_facing released_on: 2026-10-25 -pr_number: 2844 +pr_number: 2857 summary: >- Every search box now works the same way: several words all have to match, "--" means either, and quotes make a phrase. The Contact info boxes on people and diff --git a/db/seeds/features/2026-10-25-prefixed-contact-search-on-attendees.yml b/db/seeds/features/2026-10-25-prefixed-contact-search-on-attendees.yml index 13972b5561..6083e31ac4 100644 --- a/db/seeds/features/2026-10-25-prefixed-contact-search-on-attendees.yml +++ b/db/seeds/features/2026-10-25-prefixed-contact-search-on-attendees.yml @@ -2,7 +2,7 @@ name: "Prefixed contact search on the attendees page" area: reporting display_status: admin_facing released_on: 2026-10-25 -pr_number: 2844 +pr_number: 2857 summary: >- The attendees page now has the same admin-only "Contact info" box as the people index, where you can type a prefix to search one part of someone's address — From e2ea7e41332120c35a6015189a67dce9b45ded26 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 13:55:24 -0400 Subject: [PATCH 3/6] Build the prefixed search beside the people index, not over it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last pass generalised the people index's Contact info box by rewriting it: its scopes moved into the concern, its grammar changed from "either" to "all", and its hint became a generated constant. That is someone else's page, shipped this week, and nothing here needed it to change. Person keeps search_name_email and search_contact exactly as the people index has always had them, and gains search_location alongside — the same idea with the wider prefix set, the shared grammar and live addresses only. `as:` on searches_contact exists so a model that already has a search of its own can keep it. The controller, the view and the request specs are byte-identical to main again; the one line still gone from search_by_params is the dead contact_info branch nothing reaches. The specs for `type:`, for retired addresses and for the AND/OR grammar moved to the attendees page, which is where that behaviour now lives. Writing them turned up that a bare trailing `or` is SearchCop's operator, so Oregon has to be asked for as `state: OR` — said in the hint on the boxes that take prefixes. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 2 +- app/controllers/events_controller.rb | 6 +-- app/controllers/people_controller.rb | 2 + app/models/concerns/contact_searchable.rb | 22 ++++++---- app/models/person.rb | 28 +++++++++---- app/views/events/_attendees_search.html.erb | 13 +++--- app/views/people/_search_boxes.html.erb | 4 +- ...026-10-25-one-search-syntax-everywhere.yml | 20 ++++----- ...5-prefixed-contact-search-on-attendees.yml | 17 ++++---- spec/requests/events/attendees_spec.rb | 41 +++++++++++++++++++ spec/requests/people_search_spec.rb | 40 ------------------ 11 files changed, 108 insertions(+), 87 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2e14850f24..d7076cdc16 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -146,7 +146,7 @@ This codebase (Rails 8.1) | `AgeGroupTaggable` | Splits AgeRange category taggings into primary/additional via `categorizable_items.is_primary` (Person). `replace: true` treats the submitted set as the whole truth; demotions save row by row so they reach the change log | | `AhoyTrackable` | Event tracking integration. Included by the tag join models (`SectorableItem`, `CategorizableItem`) too, so a submission's tag adds/removals/primary flips reach `FormSubmissionChanges` | | `CategoriesTaggable` | Enforces a single primary category via `categorizable_items.is_primary` (Story, StoryIdea); any type validates, but the story forms and `TagAssignable` star only `AUDIENCE_CATEGORY_TYPES` (AgeRange + StoryPopulation). Also `audience_categories` | -| `ContactSearchable` | One definition of the prefixed "find them by where they are" search, shared by the people index, the attendees index and the organizations index (`searches_contact :contact_methods, :active_addresses` → a `search_contact` scope wrapping SearchCop's `contact_matches`, distinct built in). `CONTACT_SEARCH_FIELDS` maps the prefix someone types to its column(s) — phone, street, city, state, zip, county, country, locality, district, the three LA districts, and `type:` for work/personal/mailing. SearchCop ANDs the prefixes onto the **same joined address**, so `type: personal city: austin` is how you search a personal city alone. `SCOPING_PREFIXES` stay out of the bare-term catch-all (`type:` is a word streets are called; the LA districts are integer columns). Searches **live addresses only** (`active_addresses`) — a retired address is still the right place to show someone (`Address::STANDING`) but a wrong place to find them. Grammar, shared by EVERY search box in the app: several words AND (typing more finds less, in any word order), `--` means either (`ALTERNATIVE_SEPARATOR`), quotes make a phrase, commas become spaces unless the query is quoted. The two combine — `amy jones--mary davis` is `(amy AND jones) OR (mary AND davis)`, which is why `expand_alternatives` parenthesises each alternative before SearchCop sees it: unparenthesised, `or` binds to the last word alone and the query matches nobody. The non-SearchCop boxes reach the same grammar through `alternatives` (`EventRegistration.keyword` ANDs each word's whole OR block; `ReminderRecipientFilter#any_term?` ANDs the words of each alternative). `SEARCH_SYNTAX_HINT` / `CONTACT_SEARCH_HINT` caption every box from these constants so help text can't drift. Only the prefixes whose column sits on an association the model names get defined, so Organization (addresses, no contact methods) includes it with one line | +| `ContactSearchable` | The prefixed "find them by where they are" search, generalised out of the people index's Contact info box (`PeopleController`, Justin's #2825) and shared by the attendees page and the organizations index. **It does not replace that box** — `Person` keeps `search_contact` exactly as the people index has always had it and takes this one alongside as `search_location` (`searches_contact :contact_methods, :active_addresses, as: :search_location`); `as:` exists for that. `CONTACT_SEARCH_FIELDS` maps the prefix someone types to its column(s) — phone, street, city, state, zip, county, country, locality, district, the three LA districts, and `type:` for work/personal/mailing. SearchCop ANDs the prefixes onto the **same joined address**, so `type: personal city: austin` is how you search a personal city alone. `SCOPING_PREFIXES` stay out of the bare-term catch-all (`type:` is a word streets are called; the LA districts are integer columns). Searches **live addresses only** (`active_addresses`) — a retired address is still the right place to show someone (`Address::STANDING`) but a wrong place to find them. Grammar for the boxes that use it: several words AND (in any word order), `--` means either (`ALTERNATIVE_SEPARATOR`), quotes make a phrase, commas become spaces unless the query is quoted. The two combine — `amy jones--mary davis` is `(amy AND jones) OR (mary AND davis)`, which is why `expand_alternatives` parenthesises each alternative before SearchCop sees it: unparenthesised, `or` binds to the last word alone and the query matches nobody. (For the same reason Oregon has to be asked for as `state: OR`; a bare `or` is the operator.) The non-SearchCop boxes reach the same grammar through `alternatives` (`EventRegistration.keyword` ANDs each word's whole OR block; `ReminderRecipientFilter#any_term?` ANDs the words of each alternative). `SEARCH_SYNTAX_HINT` / `CONTACT_SEARCH_HINT` caption those boxes from these constants so help text can't drift — **the people index keeps its own hardcoded hint**, because its box keeps its own grammar | | `Communicable` | Gives a record the `notifications` association, nested attributes, a default `communications_scope`, and a `before_validation` addressing a hand-logged communication to the record's `communications_email`. Included by every model whose form renders `shared/_comments_and_communications` | | `AuthorCreditable` | Author attribution. Credits are formatted by the credited **person's profile** (`Person#display_name_preference`), not by the record. The record's `author_credit_preference` is the consent snapshot taken at create time and is human-editable only on the author credit divergences page. Anonymity is a one-way latch: the profile or the record can set it, neither can strip it from the other. `credits_to_org` flips the unattributed label to "AWBW Staff"; `credits_creator` (idea models, workshop logs) credits the submitting account's person when no author is named | | `Featureable` | `featured`, `publicly_featured` scopes | diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index 9c3014414d..6dbb88923f 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -846,9 +846,9 @@ def filtered_attendees # Dash-joined person ids, e.g. from the reports-hub participation totals. scope = scope.where(id: params[:registrant_ids].to_s.split("-")) if params[:registrant_ids].present? scope = scope.search_name_email(params[:name_email]) if params[:name_email].present? - # The prefixed contact search the people index uses (ContactSearchable), over - # the person's own live addresses and phones. - scope = scope.search_contact(params[:contact_info]) if params[:contact_info].present? + # ContactSearchable's prefixed search over the person's own live addresses and + # phones — the people index's box generalised, not the box itself. + scope = scope.search_location(params[:contact_info]) if params[:contact_info].present? scope = scope.where(id: person_sector_ids(params[:sector])) if params[:sector].present? scope = scope.where(id: person_affiliation_status_ids(params[:affiliation_status])) if params[:affiliation_status].present? # Breakdown drill-in filters (set when a chart row is clicked). diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 84782b38c9..13da23da18 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -33,6 +33,7 @@ def index filtered = base_scope.search_by_params(filters) 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 @@ -53,6 +54,7 @@ def email_addresses people = Person.includes(:user).search_by_params(filters) 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/concerns/contact_searchable.rb b/app/models/concerns/contact_searchable.rb index 16bc83f55b..29ec70b6ee 100644 --- a/app/models/concerns/contact_searchable.rb +++ b/app/models/concerns/contact_searchable.rb @@ -48,10 +48,14 @@ module ContactSearchable # The one help hint, shared by every search box so none of them can describe a # syntax the others don't take. SEARCH_SYNTAX_HINT = "Several words all have to match. Use -- for either (amy--aisha), quotes for a phrase.".freeze + # `or` is SearchCop's own operator, so Oregon has to be asked for by prefix + # (`state: OR`) rather than typed bare — worth saying in the box that takes + # prefixes, not in the general hint. CONTACT_SEARCH_HINT = [ "Prefixes: #{CONTACT_SEARCH_FIELDS.keys.map { |prefix| "#{prefix}:" }.join(', ')}.", "Several narrow to one address (type: personal city: austin).", - SEARCH_SYNTAX_HINT + SEARCH_SYNTAX_HINT, + "Write Oregon as state: OR — a bare or means \"either\"." ].join(" ").freeze # The alternatives someone typed. A lone hyphenated value ("Smith-Jones") is one @@ -73,10 +77,11 @@ def self.expand_alternatives(query) end class_methods do - # Defines `search_contact` over the CONTACT_SEARCH_FIELDS that live on the - # associations named here, so a model without one (an organization has - # addresses but no contact methods) offers the prefixes it can answer. - def searches_contact(*associations) + # Defines a prefixed search scope over the CONTACT_SEARCH_FIELDS that live on + # the associations named here, so a model without one (an organization has + # addresses but no contact methods) offers the prefixes it can answer. `as:` + # names the scope, for a model that already has a search of its own to keep. + def searches_contact(*associations, as: :search_contact) tables = associations.map { |association| reflect_on_association(association).table_name } fields = CONTACT_SEARCH_FIELDS.filter_map do |prefix, columns| reachable = Array(columns).select { |column| tables.include?(column.split(".").first) } @@ -84,7 +89,7 @@ def searches_contact(*associations) end.to_h searched = fields.except(*SCOPING_PREFIXES).values.flatten - search_scope :contact_matches do + search_scope :"#{as}_matches" do scope { left_joins(*associations) } fields.each { |prefix, columns| attributes prefix => columns } attributes all: searched @@ -96,9 +101,10 @@ def searches_contact(*associations) # Distinct here rather than at each call site: the joins mean an owner with # two matching addresses would otherwise arrive twice, and a caller that # forgets only finds out when a duplicate row reaches a page. - scope :search_contact, ->(query) { + matcher = :"#{as}_matches" + scope as, ->(query) { expanded = ContactSearchable.expand_alternatives(query) - expanded.blank? ? all : contact_matches(expanded).distinct + expanded.blank? ? all : public_send(matcher, expanded).distinct } end end diff --git a/app/models/person.rb b/app/models/person.rb index c91f7f532c..18a6215e21 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -211,18 +211,32 @@ def known_email_2_type? = email_2_type.in?(CONTACT_TYPES) options :all, type: :text, default: true, default_operator: :or end - search_scope :name_email_matches do + 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 ] - # "amy jones" means the one person, not everyone called either. See - # ContactSearchable for the shared grammar. - options :all, type: :text, default: true, default_operator: :and + options :all, type: :text, default: true, default_operator: :or + end + + search_scope :search_contact do + scope { left_joins(:contact_methods, :addresses) } + 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.county", "addresses.country" ] + options :all, type: :text, default: true, default_operator: :or end - # Takes the app's `--` for "either", the same as the Contact info box beside it. - scope :search_name_email, ->(query) { name_email_matches(ContactSearchable.expand_alternatives(query)) } + # Alongside `search_contact` above, not in place of it: the people index keeps + # the box it has. This one answers the attendees page, over live addresses and + # with the wider prefix set. include ContactSearchable - searches_contact :contact_methods, :active_addresses + searches_contact :contact_methods, :active_addresses, as: :search_location FIRST_NAME_HIDDEN_PREFERENCES = %w[last_name_only].freeze LAST_NAME_HIDDEN_PREFERENCES = %w[first_name_last_initial first_name_only].freeze diff --git a/app/views/events/_attendees_search.html.erb b/app/views/events/_attendees_search.html.erb index 6e5c2205ea..23fb5c41cf 100644 --- a/app/views/events/_attendees_search.html.erb +++ b/app/views/events/_attendees_search.html.erb @@ -27,9 +27,10 @@ <% chips = render "events/attendees_chips" %> <% primary = capture do %> + <%# Person.search_name_email — the people index's own scope, borrowed whole so + the same words find the same people on both pages. %> <%= render "shared/filter_text", param: :name_email, label: "Name or email", - field_class: field_class, submit: true, tooltip: ContactSearchable::SEARCH_SYNTAX_HINT, - wrapper_class: "w-full md:flex-1 md:min-w-[14rem]" %> + field_class: field_class, submit: true, wrapper_class: "w-full md:flex-1 md:min-w-[14rem]" %> <%# Attendance and event type default to "attended" / "trainings" — the population this page has always shown — so the neutral "All …" is the first option rather @@ -103,10 +104,10 @@ selected: Array(params[:topic_subscription]).map(&:to_i), placeholder: "Any topic" %> <% end %> - <%# The same prefixed box as the people index (ContactSearchable), admin-only - there and here. It reads the person's OWN contact record, while the City / - State / County filters below read where they work on these registrations — - so the two answer different questions and the labels say which. %> + <%# The people index's Contact info box generalised (ContactSearchable) — more + prefixes, live addresses only, and the shared grammar — admin-only here as + it is there. It reads the person's OWN contact record, while the box below + reads where they work on these registrations, so the labels say which. %> <% if allowed_to?(:manage?, Person) %> <%= render "shared/filter_text", param: :contact_info, label: "Their own contact info", submit: true, placeholder: "city: austin", field_class: field_class, diff --git a/app/views/people/_search_boxes.html.erb b/app/views/people/_search_boxes.html.erb index df19d30880..59fca28040 100644 --- a/app/views/people/_search_boxes.html.erb +++ b/app/views/people/_search_boxes.html.erb @@ -9,7 +9,7 @@
<%= render "shared/filter_text", param: :name_email, label: "Name or email", submit: true, - tooltip: ContactSearchable::SEARCH_SYNTAX_HINT, wrapper_class: "w-full md:flex-1" %> + wrapper_class: "w-full md:flex-1" %> <%= render "shared/filter_text", param: :organization_name, label: "Organization", submit: true, @@ -74,7 +74,7 @@ <%= render "shared/filter_text", param: :contact_info, label: "Contact info", submit: true, wrapper_class: "w-full", - tooltip: ContactSearchable::CONTACT_SEARCH_HINT %> + tooltip: "Prefixes: phone:, street:, city:, state:, zip:, county:, country:" %>
<% end %> diff --git a/db/seeds/features/2026-10-25-one-search-syntax-everywhere.yml b/db/seeds/features/2026-10-25-one-search-syntax-everywhere.yml index 59ec56e8e2..1728eef3d8 100644 --- a/db/seeds/features/2026-10-25-one-search-syntax-everywhere.yml +++ b/db/seeds/features/2026-10-25-one-search-syntax-everywhere.yml @@ -1,19 +1,17 @@ -name: "One search syntax across every filter box" +name: "Prefixed search on the attendees and organizations pages" area: reporting display_status: admin_facing released_on: 2026-10-25 pr_number: 2857 summary: >- - Every search box now works the same way: several words all have to match, "--" - means either, and quotes make a phrase. The Contact info boxes on people and - attendees, and the Address box on organizations, take prefixes like "city:" and - "county:". + The people index's Contact info box now has siblings: the attendees page gets + one of its own, and the organizations Address box takes the same prefixes. The + roster keyword search and the bulk email picker now read "--" as "either", the + way tag filters already do. pro_tips: - - "Several words narrow: amy jones finds the one person, not everyone called Amy plus everyone called Jones." - - "amy--aisha finds both. That works in every box, including the roster keyword search and the bulk email picker." - - "The two rules combine: amy jones--mary davis finds Amy Jones and Mary Davis, and nobody called Amy Davis." - - "Word order does not matter: jones amy finds Amy Jones." - "Prefixes: phone:, street:, city:, state:, zip:, county:, country:, locality:, district:, la_council:, la_supervisorial:, la_spa:, and type: for work or personal." - "type: personal city: austin asks for one address that is BOTH personal and in Austin — that is how to search a personal city on its own." - - "Contact info searches someone's own live addresses. Where they work searches the organization address on their registrations. The two can disagree, on purpose." - - "Hover the little i beside any search box to see what it takes." + - "In these boxes several words all have to match, in any order, and amy--aisha finds either. The two combine: amy jones--mary davis finds Amy Jones and Mary Davis, not Amy Davis." + - "Write Oregon as state: OR. A bare or means either." + - "These boxes search live addresses only. A retired address still places someone on the charts, but is not how you look them up." + - "The people index search is unchanged — it works exactly as it did." diff --git a/db/seeds/features/2026-10-25-prefixed-contact-search-on-attendees.yml b/db/seeds/features/2026-10-25-prefixed-contact-search-on-attendees.yml index 6083e31ac4..71d2fe2c3d 100644 --- a/db/seeds/features/2026-10-25-prefixed-contact-search-on-attendees.yml +++ b/db/seeds/features/2026-10-25-prefixed-contact-search-on-attendees.yml @@ -1,15 +1,14 @@ -name: "Prefixed contact search on the attendees page" +name: "Attendees page: search by contact info and by where they work" area: reporting display_status: admin_facing released_on: 2026-10-25 pr_number: 2857 summary: >- - The attendees page now has the same admin-only "Contact info" box as the people - index, where you can type a prefix to search one part of someone's address — - "city: austin", "county: travis", "zip: 78701". Its main search box is now name - or email, matching the people index. + The attendees page's search box is now Name or email, matching the people + index, with two new boxes beside it: a super-admin "Their own contact info" + box, and a "Where they work" box that replaces the old City field. pro_tips: - - "Prefixes: phone:, street:, city:, state:, zip:, county:, country:, locality:, district:. Type several and each narrows the list further." - - "With no prefix, the term is searched across all of those fields at once." - - "This box reads the person's OWN address, while the City, State and County filters next to it read where they work on these registrations — so the two can disagree, on purpose." - - "Both boxes are super-admin only; nobody else sees them." + - "Their own contact info reads the person's own address. Where they work reads the organization address on their registrations. The two can disagree, on purpose." + - "Where they work takes city:, county:, state:, country:, locality:, zip:, district: and the LA district prefixes — so locality and school district are filterable here for the first time." + - "The State and County dropdowns still win over the same field typed into the box." + - "Both new boxes are super-admin only." diff --git a/spec/requests/events/attendees_spec.rb b/spec/requests/events/attendees_spec.rb index fd61defd44..8c30e981c7 100644 --- a/spec/requests/events/attendees_spec.rb +++ b/spec/requests/events/attendees_spec.rb @@ -612,6 +612,47 @@ expect(response.body).not_to include("Ada Lovelace") end + # `type:` rides on the same joined address as the other prefixes, which is + # how you search a personal city alone rather than any address's city. + it "narrows the contact search to one kind of address with type:" do + create(:address, addressable: attendee, city: "Homecity", address_type: "personal", inactive: false) + create(:address, addressable: attendee, city: "Deskcity", address_type: "work", inactive: false) + + get attendees_events_url(contact_info: "type: personal city: Homecity"), headers: frame_headers + expect(response.body).to include("Ada Lovelace") + + get attendees_events_url(contact_info: "type: work city: Homecity"), headers: frame_headers + expect(response.body).not_to include("Ada Lovelace") + end + + # Retired addresses place someone on the map (Address::STANDING) but should + # not be how you find them — the sticky note with their old address would + # otherwise be the one thing that works. + it "does not match a retired address" do + create(:address, addressable: attendee, city: "Oldcity", inactive: true) + + get attendees_events_url(contact_info: "city: Oldcity"), headers: frame_headers + expect(response.body).not_to include("Ada Lovelace") + end + + # Words AND inside an alternative, alternatives OR across. Each side is + # parenthesised on the way into SearchCop to get that — unparenthesised the + # `or` binds to the last word alone and the query matches nobody. (Oregon is + # spelled `state: OR` here for the same reason: a bare `or` is the operator.) + it "ANDs the words inside each -- alternative and ORs the alternatives" do + create(:address, addressable: attendee, city: "Austin", state: "TX", inactive: false) + other = create(:person, first_name: "Katherine", last_name: "Johnson") + create(:address, addressable: other, city: "Seattle", state: "WA", inactive: false) + create(:event_registration, event: recent_training, registrant: other, status: "attended") + neither = create(:person, first_name: "Grace", last_name: "Hopper") + create(:address, addressable: neither, city: "Austin", state: "WA", inactive: false) + create(:event_registration, event: recent_training, registrant: neither, status: "attended") + + get attendees_events_url(contact_info: "austin tx--seattle wa"), headers: frame_headers + expect(response.body).to include("Ada Lovelace", "Katherine Johnson") + expect(response.body).not_to include("Grace Hopper") + end + # One box, prefixed like the contact box above it, so every part still has # to describe the same work address. it "filters by the prefixed Where they work box" do diff --git a/spec/requests/people_search_spec.rb b/spec/requests/people_search_spec.rb index 1a3ff865d2..78786ce669 100644 --- a/spec/requests/people_search_spec.rb +++ b/spec/requests/people_search_spec.rb @@ -379,46 +379,6 @@ def search_contact_details(term) expect(search_contact_details("Hiddenway")).to include("Hiddenfacts") end - # `type:` rides on the same joined address as the other prefixes, so it is how - # you search a personal city alone rather than any address's city. - it "narrows the contact search to one kind of address with type:" do - create(:address, addressable: person, city: "Hiddencity", address_type: "personal") - create(:address, addressable: person, city: "Workcity", address_type: "work") - - expect(search_contact_details("type: personal city: Hiddencity")).to include("Hiddenfacts") - expect(search_contact_details("type: work city: Hiddencity")).not_to include("Hiddenfacts") - expect(search_contact_details("type: work city: Workcity")).to include("Hiddenfacts") - end - - # The two rules meet here: words AND inside an alternative, alternatives OR - # across. Each side is parenthesised on the way into SearchCop to get that — - # unparenthesised, the `or` binds to the last word alone and "amy jones--mary - # davis" quietly becomes "amy AND (jones OR mary) AND davis", which matches - # nobody. - it "ANDs the words inside each -- alternative and ORs the alternatives" do - amy = create(:person, first_name: "Amyname", last_name: "Jonesname") - mary = create(:person, first_name: "Maryname", last_name: "Davisname") - mixed = create(:person, first_name: "Amyname", last_name: "Davisname") - - body = search("amyname jonesname--maryname davisname") - - expect(body).to include(amy.first_name, mary.first_name) - expect(body).to include("Jonesname", "Davisname") - expect(body).not_to include(mixed.name) - end - - # Every search box in the app takes "--" for "either"; the prefixed boxes get - # it by rewriting it into SearchCop's own `or`. - it "takes -- alternatives in both boxes" do - other = create(:person, first_name: "Otherfacts") - create(:address, addressable: other, city: "Othercity") - create(:address, addressable: person, city: "Hiddencity") - - expect(search("Hiddenfacts--Otherfacts")).to include("Hiddenfacts", "Otherfacts") - body = search_contact_details("city: Hiddencity--city: Othercity") - expect(body).to include("Hiddenfacts", "Otherfacts") - end - %w[first_name_last_initial first_name_only].each do |preference| it "matches the last name when the display preference is #{preference}" do person.update!(last_name: "Hiddensurname", display_name_preference: preference) From 1fc456f826c0023cb8b77088ecca8b9e68710fbc Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 14:18:09 -0400 Subject: [PATCH 4/6] Place the Where they work specs by affiliation, as PersonLocations does PersonLocations reads where someone works from their active affiliations, so a work address hung off the registration no longer places anyone. Co-Authored-By: Claude Opus 5.5 (1M context) --- spec/requests/events/attendees_spec.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/requests/events/attendees_spec.rb b/spec/requests/events/attendees_spec.rb index 8c30e981c7..a6ec5c3c04 100644 --- a/spec/requests/events/attendees_spec.rb +++ b/spec/requests/events/attendees_spec.rb @@ -659,12 +659,12 @@ org = create(:organization) create(:address, addressable: org, city: "Austin", state: "TX", county: "Travis", zip_code: "78701", locality: Address::OUTSIDE_CA, inactive: false) - attendee_registration.event_registration_organizations.create!(organization: org) + create(:affiliation, person: attendee_registration.registrant, organization: org) other = create(:person, first_name: "Katherine", last_name: "Johnson") other_registration = create(:event_registration, event: recent_training, registrant: other, status: "attended") other_org = create(:organization) create(:address, addressable: other_org, city: "Portland", state: "OR", county: "Multnomah", inactive: false) - other_registration.event_registration_organizations.create!(organization: other_org) + create(:affiliation, person: other, organization: other_org) get attendees_events_url(work_location: "city: austin"), headers: frame_headers expect(response.body).to include("Ada Lovelace") @@ -680,7 +680,7 @@ it "lets the State dropdown win over the same field typed in the box" do org = create(:organization) create(:address, addressable: org, city: "Austin", state: "TX", inactive: false) - attendee_registration.event_registration_organizations.create!(organization: org) + create(:affiliation, person: attendee_registration.registrant, organization: org) get attendees_events_url(work_location: "state: OR city: austin", state: "TX"), headers: frame_headers expect(response.body).to include("Ada Lovelace") @@ -689,7 +689,7 @@ it "matches nobody when the State dropdown contradicts the county's own state" do org = create(:organization) create(:address, addressable: org, city: "Portland", state: "OR", county: "Multnomah", inactive: false) - attendee_registration.event_registration_organizations.create!(organization: org) + create(:affiliation, person: attendee_registration.registrant, organization: org) get attendees_events_url(state: "OR", county: "WA::King"), headers: frame_headers expect(response).to have_http_status(:ok) From 9c4b564f48e7142d6e4628e886e32663660bc1a8 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 14:18:09 -0400 Subject: [PATCH 5/6] Let admins narrow the people index by where people work The attendees page's Where they work query now reaches the people index and its email export through a work_location param, placed by PersonLocations as of today. It runs after the existing name/email and contact scopes, so the people index query is unchanged whenever the param is absent; the visible box comes in a later step. Admin-only, like contact_info. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/controllers/people_controller.rb | 10 ++++ app/policies/person_policy.rb | 2 +- spec/policies/person_policy_spec.rb | 7 +++ spec/requests/people_email_addresses_spec.rb | 13 ++++ spec/requests/people_search_spec.rb | 62 ++++++++++++++++++++ 5 files changed, 93 insertions(+), 1 deletion(-) diff --git a/app/controllers/people_controller.rb b/app/controllers/people_controller.rb index 13da23da18..5f9ef75eee 100644 --- a/app/controllers/people_controller.rb +++ b/app/controllers/people_controller.rb @@ -30,10 +30,12 @@ def index filters = authorized_scope(params, as: :index).to_unsafe_h name_email = filters.delete(:name_email) contact_info = filters.delete(:contact_info) + work_location = filters.delete(:work_location) filtered = base_scope.search_by_params(filters) filtered = filtered.search_name_email(name_email) filtered = filtered.search_contact(contact_info) filtered = filtered.distinct if contact_info.present? + filtered = located_by_work_location(filtered, work_location) if work_location.present? filtered = filtered.order(:first_name, :last_name) total = filtered.count @@ -51,10 +53,12 @@ def email_addresses filters = params.to_unsafe_h name_email = filters.delete(:name_email) contact_info = filters.delete(:contact_info) + work_location = filters.delete(:work_location) people = Person.includes(:user).search_by_params(filters) people = people.search_name_email(name_email) people = people.search_contact(contact_info) people = people.distinct if contact_info.present? + people = located_by_work_location(people, work_location) if work_location.present? @email_addresses = people.filter_map { |person| person.preferred_email.presence }.uniq.sort end @@ -379,6 +383,12 @@ def check_duplicates private + def located_by_work_location(people, query) + filters = PersonLocations.filters_from(**PersonLocations.parse_query(query)) + return people.none if filters.nil? + people.where(id: PersonLocations.new(people.select(:id)).matching(**filters).map(&:registrant_id)) + end + def dedupe_config { model_class: Person, diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index 22615a24b3..7e94ec5f9a 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 contact_info + staff_tag_ids topic_subscription_type_id contact_info work_location ].freeze SHARED_ONLY_FILTERS = { diff --git a/spec/policies/person_policy_spec.rb b/spec/policies/person_policy_spec.rb index 569594b314..e9c13f5bbf 100644 --- a/spec/policies/person_policy_spec.rb +++ b/spec/policies/person_policy_spec.rb @@ -471,6 +471,13 @@ def index_filters_for(user) expect(index_filters_for(nil).keys).to contain_exactly(*shared_filter_keys) end + it "drops the work location filter for a regular user" do + filters = ActionController::Parameters.new(work_location: "city: reno") + scoped = policy_for(record: Person, user: regular_user) + .apply_scope(filters, type: :action_controller_params, name: :index) + expect(scoped.keys).not_to include("work_location") + end + it "narrows a non-admin's contact-info search to shared contact info" do expect(index_filters_for(regular_user)[:shared_contact_info]).to eq("ada") end diff --git a/spec/requests/people_email_addresses_spec.rb b/spec/requests/people_email_addresses_spec.rb index 8421d88f48..7534c73403 100644 --- a/spec/requests/people_email_addresses_spec.rb +++ b/spec/requests/people_email_addresses_spec.rb @@ -29,6 +29,19 @@ expect(response.body).to include("carol@example.com") end + it "narrows by where people work" do + organization = create(:organization) + create(:address, addressable: organization, city: "Reno", state: "NV") + worker = create(:person, email: "reno@example.com", user: nil) + create(:affiliation, person: worker, organization: organization) + create(:person, email: "elsewhere@example.com", user: nil) + + get email_addresses_people_path(work_location: "city: reno") + + expect(response.body).to include("reno@example.com") + expect(response.body).not_to include("elsewhere@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) diff --git a/spec/requests/people_search_spec.rb b/spec/requests/people_search_spec.rb index 78786ce669..be317d5a59 100644 --- a/spec/requests/people_search_spec.rb +++ b/spec/requests/people_search_spec.rb @@ -519,4 +519,66 @@ def tag(person, sector:, age:, primary:) expect(page).to have_css("input[type=hidden][name=organization_id][value='#{org.id}']", visible: :all) end end + + describe "GET /people (turbo frame) work location filter" do + let(:organization) { create(:organization) } + let!(:worker) { create(:person, first_name: "Renoworker") } + let!(:homebody) { create(:person, first_name: "Boisehome") } + let!(:unplaced) { create(:person, first_name: "Noaddress") } + + before do + create(:address, addressable: organization, city: "Reno", state: "NV") + create(:affiliation, person: worker, organization: organization) + create(:address, addressable: homebody, city: "Boise", state: "ID") + end + + def search_work_location(query, **params) + get people_path, params: { work_location: query, **params }, headers: turbo_headers + response + end + + it "renders the results frame" do + expect(search_work_location("state: NV")).to have_http_status(:ok) + expect(response.body).to include('id="people_results"') + end + + it "narrows to people located by their work address" do + body = search_work_location("city: reno").body + expect(body).to include("Renoworker") + expect(body).not_to include("Boisehome", "Noaddress") + end + + it "falls back to the person's own address" do + body = search_work_location("state: ID").body + expect(body).to include("Boisehome") + expect(body).not_to include("Renoworker", "Noaddress") + end + + it "matches a bare term against any location field" do + expect(search_work_location("boise").body).to include("Boisehome") + end + + it "matches nobody when the county contradicts the state" do + body = search_work_location("state: NV county: \"CA::Los Angeles\"").body + expect(body).not_to include("Renoworker", "Boisehome") + end + + it "keeps the filter on the page links" do + other = create(:person, first_name: "Renocolleague") + create(:affiliation, person: other, organization: organization) + + body = search_work_location("city: reno", number_of_items_per_page: 1).body + expect(body).to include("work_location=city") + end + + it "is ignored for a non-admin" do + sign_in create(:user) + [ worker, homebody ].each do |person| + person.update!(profile_is_searchable: true) + create(:affiliation, person: person, title: "Facilitator", start_date: 1.year.ago, inactive: false) + end + + expect(search_work_location("city: reno").body).to include("Renoworker", "Boisehome") + end + end end From fe1afedb68d01ffbc96475eedce8e85db6c1ef80 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 14:52:56 -0400 Subject: [PATCH 6/6] Describe the bare city chip by where it comes from Co-Authored-By: Claude Opus 5.5 (1M context) --- app/services/attendees_active_filters.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/services/attendees_active_filters.rb b/app/services/attendees_active_filters.rb index c74e22034c..f120e88121 100644 --- a/app/services/attendees_active_filters.rb +++ b/app/services/attendees_active_filters.rb @@ -48,8 +48,7 @@ def label_for(param, value) when "sector" then record_label("Sector", Sector, value) when "organization_id" then record_label("Organization", Organization, value) when "org_city" then "City: #{value}" - # The City box moved into the prefixed "Where they work" box, so a bare city - # param now arrives from a link or an older bookmark with nothing to show it. + # A bare city param comes from a link or bookmark; no visible control shows it. when "city" then "City: #{value}" when "age_group" then record_label("Age group", Category, value) when "life_experience" then record_label("Life experience", Category, value)