diff --git a/AGENTS.md b/AGENTS.md index 0243f14d11..d7076cdc16 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` | 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 | @@ -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..6dbb88923f 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? + # 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). @@ -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..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/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..29ec70b6ee --- /dev/null +++ b/app/models/concerns/contact_searchable.rb @@ -0,0 +1,111 @@ +# 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 + # `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, + "Write Oregon as state: OR — a bare or means \"either\"." + ].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 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) } + [ prefix, reachable ] if reachable.any? + end.to_h + searched = fields.except(*SCOPING_PREFIXES).values.flatten + + search_scope :"#{as}_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. + matcher = :"#{as}_matches" + scope as, ->(query) { + expanded = ContactSearchable.expand_alternatives(query) + expanded.blank? ? all : public_send(matcher, 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..18a6215e21 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 @@ -229,6 +232,12 @@ def known_email_2_type? = email_2_type.in?(CONTACT_TYPES) options :all, type: :text, default: true, default_operator: :or end + # 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, 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 @@ -447,7 +456,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/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/app/services/attendees_active_filters.rb b/app/services/attendees_active_filters.rb index df5982ef52..f120e88121 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,8 @@ 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}" + # 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) 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..23fb5c41cf 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,7 +27,9 @@ <% chips = render "events/attendees_chips" %> <% primary = capture do %> - <%= render "shared/filter_text", param: :contact_info, label: "Name, email, or phone", + <%# 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, wrapper_class: "w-full md:flex-1 md:min-w-[14rem]" %> <%# Attendance and event type default to "attended" / "trainings" — the population @@ -102,8 +104,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 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, + 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/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..1728eef3d8 --- /dev/null +++ b/db/seeds/features/2026-10-25-one-search-syntax-everywhere.yml @@ -0,0 +1,17 @@ +name: "Prefixed search on the attendees and organizations pages" +area: reporting +display_status: admin_facing +released_on: 2026-10-25 +pr_number: 2857 +summary: >- + 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: + - "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." + - "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 new file mode 100644 index 0000000000..71d2fe2c3d --- /dev/null +++ b/db/seeds/features/2026-10-25-prefixed-contact-search-on-attendees.yml @@ -0,0 +1,14 @@ +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'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: + - "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/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/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/events/attendees_spec.rb b/spec/requests/events/attendees_spec.rb index d28f91c93f..a6ec5c3c04 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,10 +612,84 @@ 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 + org = create(:organization) + create(:address, addressable: org, city: "Austin", state: "TX", county: "Travis", + zip_code: "78701", locality: Address::OUTSIDE_CA, inactive: false) + 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) + 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") + 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) + 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") + 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) - 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) @@ -653,8 +752,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_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 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")