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('