diff --git a/AGENTS.md b/AGENTS.md index 08004bfca8..9df72a73f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -263,6 +263,7 @@ or symbol. So write `authorize! @setting` in `show` (not `authorize! @setting, t - `ModelDeduper` — Deduplication logic. On a natural-key collision it now merges the losing row into the survivor (recursively, via itself) so the loser's own children move first — instead of destroying the loser and cascading them away or aborting on a protective `before_destroy` (e.g. a professional license with CE registrations). It finds reassignable children two ways: the model's own `has_many` declarations, and a scan of the DB's foreign keys. **A polymorphic child carries no foreign key**, so it is reachable only through an explicit `has_many … as:` — without one, `#unhandled_references` sees it and the merge is refused outright (this is why `Person`/`Organization` declare `has_many :invoices, as: :invoicee`). A config can also pass `reference_columns:` — column names that point at the model in any table without a DB foreign key (the users deduper names `user_id`/`created_by_id`/`updated_by_id`/`welcome_instructions_sent_by_id`). Self-references on the model's own table follow the keeper, except the keeper's own reference to the duplicate, which clears - `ContinuingEducationDeduper` — Collapses duplicate CE registrations left sharing one event registration + license after a merge, moving the losers' allocations (payments) and comments onto the survivor. Keeps the real cost (larger of the two) rather than inventing one to cover the combined payments — if they now exceed it, `ContinuingEducationRegistration#over_allocated?` flags it (`ContinuingEducationRegistrationDecorator#overpayment_badge` → "Check payments") for an admin. CE has no DB unique index, so `ModelDeduper` never compares CE rows and just moves them all onto the keeper; a unique index is deliberately avoided because it would force the engine to move CE via validated `update!`, which trips `ContinuingEducationRegistration#license_belongs_to_registrant` mid-merge. Run from the people deduper's `after_merge` - `AddressNormalizer` — Comparison keys for address parts so spellings of one place match: street suffixes/directionals/ordinals (`Street`→`st`, `North`→`n`, `First`→`1st`), unit designators (`Suite`/`Apt`/`Unit`/`#` → `#`), `Saint`/`Mount`/`Fort` in cities, state names → USPS abbreviations, ZIP+4 → five digits, US country spellings (and a blank country in a US state) → `us`, a trailing "County", and an "unknown" type/locality → blank. `.signature(address)` is the whole-address key. Matching only — stored addresses keep their spelling. Used by `AddressDeduper`, `OrganizationServices::AddressMatcher`, `OrganizationServices::DuplicateFinder`'s shared-address signal, and `PersonServices::CaptureFromSubmission`'s mailing-address match +- `CountryName` — One spelling per country (`US`/`USA`/`United States` → "United States"), via the `countries` gem plus `AddressNormalizer`'s US spellings. `.options` feeds the `*_country` form-field dropdown (US first); `.label` groups country breakdowns in `EventDashboard`, `AttendeesBreakdowns`, `FormResponseAggregator`'s world map, and the attendees country filter. Read-time only — stored countries keep their spelling - `AddressLocality` — Works out which `Address::LOCALITIES` bucket an address falls in from its own city/state/country/county, for a writer that has an address but never asked the question (`PersonServices::ImportFromRolodex`, `PersonServices::CaptureFromSubmission`, `OrganizationServices::UpsertAddress`, `ProfileChangeRequests::Apply` — all on create only, so a locality a person picked is never overwritten). Reads a named non-US country as **Outside USA**, a non-CA US state as **Outside CA**, a Los Angeles mailing city as **LA City**, the rest of Los Angeles County as **LA County**, and Orange County as **Orange County**. Everything else stays **Unknown**: the Northern/Central/Southern line through California is AWBW's own and nothing records where it runs, and a state no US state answers to could as easily be a typo as a foreign region. Every comparison goes through `AddressNormalizer`. FileMaker records a `Locality` only on a **project**, so the organizations pass (ADR-0008) carries that across rather than deriving it; a person's own address has none - `AddressDeduper` — Merges a person's or organization's addresses that are one place into a single address. `.matching_sets` groups by `AddressNormalizer.signature`, then merges a less complete copy (blank ZIP, street, country, …) into the single fuller address whose every part it shares — skipped when it fits more than one; works fullest-first so a chain of partial copies lands on the fullest. Each merge goes through `ModelDeduper#merge`, which repoints the merged address's inbound FKs — `contact_methods.address_id`, `invoices.bill_to_address_id`, `affiliations.organization_address_id` — onto the survivor. Within a set the survivor is the copy still in use — active before inactive, then the primary, then the oldest (`.keeper_order`) — since `Address.active` drives bill-to addresses, an org's program location and the geographic breakdowns; blank columns on the survivor (`CARRIED_COLUMNS` — the street/ZIP/country/county/district/type/locality, `phone` and the three `la_*` districts; "unknown" type/locality counts as blank) are filled from the merged copies. Then settles a single primary on the set holding the signature captured in the deduper's `merge_keeper` (the survivor's own pre-merge primary address, else the deleted record's), demoting the rest. Runs in the people and organizations dedupers' `after_merge`; `AddressDeduper.merge_duplicates(addressable)` runs it standalone (keeping the owner's current primary) for the **Dedupe addresses** admin page (`AddressesController#dedupe_index`/`#dedupe_perform`) - `AddressServices::DuplicateFinder` — Lists the people and organizations holding addresses that `AddressDeduper.matching_sets` would merge, for the **Dedupe addresses** page. Compares only within one owner — two orgs at one street are separate records, not duplicates diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb index bc70710f9c..819aa7721e 100644 --- a/app/controllers/events_controller.rb +++ b/app/controllers/events_controller.rb @@ -951,7 +951,9 @@ def person_category_ids(category_id) end def person_country_ids(country) - Address.active.where(addressable_type: "Person", country: country).select(:addressable_id) + addresses = Address.active.where(addressable_type: "Person") + spellings = addresses.distinct.pluck(:country).select { |spelling| CountryName.label(spelling) == CountryName.label(country) } + addresses.where(country: spellings).select(:addressable_id) end # People whose event-registration-linked affiliation points at an organization diff --git a/app/helpers/geography_helper.rb b/app/helpers/geography_helper.rb index 836d1205bd..4ec23ab316 100644 --- a/app/helpers/geography_helper.rb +++ b/app/helpers/geography_helper.rb @@ -65,4 +65,12 @@ def state_select_options(current = nil) us_states + [ [ current, current ] ] end + + # Country dropdown options, keeping an unrecognized current value selectable so a re-render doesn't drop it. + def country_select_options(current = nil) + label = CountryName.label(current) + return CountryName.options if label.blank? || CountryName.options.include?(label) + + CountryName.options + [ label ] + end end diff --git a/app/services/attendees_breakdowns.rb b/app/services/attendees_breakdowns.rb index 58d5222dbe..bb231a17e2 100644 --- a/app/services/attendees_breakdowns.rb +++ b/app/services/attendees_breakdowns.rb @@ -185,7 +185,7 @@ def state_registrant_ids_by_state end def country_registrant_ids_by_country - @country_registrant_ids_by_country ||= person_ids_by(address_rows.reject { |_, _, country| country.blank? }) { |row| row[2] } + @country_registrant_ids_by_country ||= person_ids_by(address_rows.reject { |_, _, country| country.blank? }) { |row| CountryName.label(row[2]) } end def school_district_registrant_ids_by_district diff --git a/app/services/country_name.rb b/app/services/country_name.rb new file mode 100644 index 0000000000..fdeb7d4ef9 --- /dev/null +++ b/app/services/country_name.rb @@ -0,0 +1,28 @@ +# One spelling per country, so "US", "USA" and "United States" count as the same place. +module CountryName + UNITED_STATES = "United States" + + module_function + + # Dropdown choices: the United States first, then every other country A–Z. + def options + @options ||= [ UNITED_STATES ] + (ISO3166::Country.all.map(&:common_name) - [ UNITED_STATES ]).sort + end + + # The common name for a typed country or ISO code, or nil when it isn't recognized. + def canonical(value) + text = value.to_s.strip + return if text.blank? + return UNITED_STATES if AddressNormalizer::US_COUNTRY_NAMES.include?(text.downcase.delete("^a-z")) + + country = ISO3166::Country.find_country_by_alpha2(text) || + ISO3166::Country.find_country_by_alpha3(text) || + ISO3166::Country.find_country_by_any_name(text) + country&.common_name + end + + # The common name when recognized, otherwise the value as typed. + def label(value) + canonical(value) || value.to_s.strip.presence + end +end diff --git a/app/services/event_dashboard.rb b/app/services/event_dashboard.rb index 7f78748c07..00a55900fb 100644 --- a/app/services/event_dashboard.rb +++ b/app/services/event_dashboard.rb @@ -870,24 +870,12 @@ def state_registrant_ids end def countries - @countries ||= Address - .active - .where(addressable_type: "Person", addressable_id: registrant_ids) - .where.not(country: [ nil, "" ]) - .distinct - .pluck(:country) - .sort + @countries ||= country_registrant_ids_by_country.keys.sort end # Distinct registrant count per country. def country_counts - @country_counts ||= Address - .active - .where(addressable_type: "Person", addressable_id: registrant_ids) - .where.not(country: [ nil, "" ]) - .group(:country) - .distinct - .count(:addressable_id) + @country_counts ||= country_registrant_ids_by_country.transform_values(&:size) end # Registrant ids that have at least one active address with a country on file. @@ -925,7 +913,7 @@ def country_registrant_ids_by_country .where.not(country: [ nil, "" ]) .distinct .pluck(:country, :addressable_id) - .group_by(&:first) + .group_by { |country, _| CountryName.label(country) } .transform_values { |rows| rows.map(&:last).uniq } end diff --git a/app/services/form_response_aggregator.rb b/app/services/form_response_aggregator.rb index 0864915227..e6b7873e7d 100644 --- a/app/services/form_response_aggregator.rb +++ b/app/services/form_response_aggregator.rb @@ -195,15 +195,16 @@ def numeric_value(field, raw) text.to_i if text.match?(/\A-?\d+\z/) end - # A choropleth tally of a geographic field: counts per submitted place value, - # left as-is (the map matches state/country names itself; the legend lists the - # exact counts). Multi-value answers split like any other select. + # A choropleth tally of a geographic field: counts per submitted state as-is, or + # per country under one spelling (the map matches state/country names itself; the + # legend lists the exact counts). State answers split like any other select; a + # country answer stays whole since some country names contain commas. def build_map_report(question, answers, chart) tally = Hash.new(0) answered = 0 answers.each do |answer| - values = split_values(answer.submitted_answer) + values = chart == :world_map ? Array(CountryName.label(answer.submitted_answer)) : split_values(answer.submitted_answer) next if values.empty? answered += 1 diff --git a/app/views/events/public_registrations/_form_field_input.html.erb b/app/views/events/public_registrations/_form_field_input.html.erb index d6a56afe1d..cf3ea82ac8 100644 --- a/app/views/events/public_registrations/_form_field_input.html.erb +++ b/app/views/events/public_registrations/_form_field_input.html.erb @@ -49,6 +49,17 @@ <% end %> + <% elsif field.field_identifier&.end_with?("_country") %> + <% selected_country = CountryName.label(value) %> + <% else %> <% html_type = if field.date? diff --git a/db/seeds/features/2026-10-23-country-dropdown-on-registration-forms.yml b/db/seeds/features/2026-10-23-country-dropdown-on-registration-forms.yml new file mode 100644 index 0000000000..4a81068fca --- /dev/null +++ b/db/seeds/features/2026-10-23-country-dropdown-on-registration-forms.yml @@ -0,0 +1,12 @@ +name: "Pick your country from a list on registration forms" +area: registration +display_status: user_facing +released_on: 2026-10-23 +pr_number: 2805 +summary: >- + The Country and Organization country questions on registration and other forms + are now a dropdown instead of a text box, so everyone in the US lands under one + "United States." +pro_tips: + - "The United States sits at the top of the list; every other country follows A–Z." + - "Country charts on event dashboards, the attendees page, and form results now count US, USA, and United States as one country — including answers typed before the dropdown." diff --git a/spec/helpers/geography_helper_spec.rb b/spec/helpers/geography_helper_spec.rb index 2cd5bcc77c..69622c591f 100644 --- a/spec/helpers/geography_helper_spec.rb +++ b/spec/helpers/geography_helper_spec.rb @@ -22,4 +22,15 @@ expect(helper.state_select_options("NY").count { |_name, abbr| abbr == "NY" }).to eq(1) end end + + describe "#country_select_options" do + it "returns the country list when the value is blank or a known country" do + expect(helper.country_select_options).to eq(CountryName.options) + expect(helper.country_select_options("USA")).to eq(CountryName.options) + end + + it "appends an unrecognized value so it stays selectable" do + expect(helper.country_select_options("Narnia").last).to eq("Narnia") + end + end end diff --git a/spec/requests/events/attendees_spec.rb b/spec/requests/events/attendees_spec.rb index 828aa4f56c..6e13f6eeb1 100644 --- a/spec/requests/events/attendees_spec.rb +++ b/spec/requests/events/attendees_spec.rb @@ -351,6 +351,17 @@ expect(response.body).not_to include("Zed Zulu") end + it "filters a country drill-in across every spelling of that country" do + create(:address, addressable: attendee, country: "USA", inactive: false) + other = create(:person, first_name: "Zed", last_name: "Zulu") + create(:event_registration, event: recent_training, registrant: other, status: "attended") + create(:address, addressable: other, country: "Canada", inactive: false) + + get attendees_events_url(country: "United States"), headers: frame_headers + expect(response.body).to include("Ada Lovelace") + expect(response.body).not_to include("Zed Zulu") + end + it "filters by a school-district drill-in (via the org they registered under)" do org = create(:organization, name: "Compton Schools") org_address = create(:address, addressable: org, district: "Compton Unified", inactive: false) diff --git a/spec/requests/events/public_registrations_spec.rb b/spec/requests/events/public_registrations_spec.rb index 0c8deb79d9..ace384190e 100644 --- a/spec/requests/events/public_registrations_spec.rb +++ b/spec/requests/events/public_registrations_spec.rb @@ -314,6 +314,15 @@ def post_with_scholarship(scholarship_answer) expect(response.body).to include("add or update them later") end + it "renders the country fields as a country dropdown" do + create(:form_field, form: form, field_identifier: "mailing_country", name: "Country", required: false) + + get new_event_public_registration_path(event) + + expect(response.body).to include("Select a country") + expect(response.body).to include('