From 03588e2f7bf8c05dc5fda9bd96e730f5c35c6bcb Mon Sep 17 00:00:00 2001 From: maebeale Date: Mon, 22 Jun 2026 08:09:19 -0400 Subject: [PATCH] Support international addresses with country-driven state field AWBW now serves people and orgs outside the US, but every address forced a US-state dropdown and required a US state, so international addresses couldn't be entered or registered. Drive the state field off the country instead: - Address forms swap the validated US-state dropdown for a required free-text region input whenever the country isn't the United States (new address-region Stimulus controller; country is a real dropdown via the countries gem). - Address validates state is a recognized US abbreviation only for US addresses (case-insensitive); international addresses just require a value. - Public registration persists the submitted country and keeps the state field free-text so non-US registrants can enter their region; the same model rules reject an invalid US state. - Address#name drops a blank/again-optional region without a stray separator. Co-Authored-By: Claude Opus 4.8 --- AGENTS.md | 3 +- .../controllers/address_region_controller.js | 29 +++++++ app/frontend/javascript/controllers/index.js | 3 + app/helpers/geography_helper.rb | 75 ++++++------------- app/models/address.rb | 30 +++++++- app/models/us_state.rb | 60 +++++++++++++++ .../public_registration.rb | 6 ++ app/views/addresses/_address_fields.html.erb | 52 ++++++++++--- .../public_registrations/_form_field.html.erb | 19 ++--- .../organizations/_address_fields.html.erb | 51 ++++++++++--- spec/models/address_spec.rb | 44 +++++++++++ .../public_registration_spec.rb | 31 ++++++++ ...ublic_registration_form_submission_spec.rb | 4 +- 13 files changed, 318 insertions(+), 89 deletions(-) create mode 100644 app/frontend/javascript/controllers/address_region_controller.js create mode 100644 app/models/us_state.rb diff --git a/AGENTS.md b/AGENTS.md index df764e7b9f..8180b2745d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ This codebase (Rails 8.1) | Directory | Purpose | |---|---| | `app/frontend/entrypoints/` | Vite entry points (application.js, application.css) | -| `app/frontend/javascript/controllers/` | Stimulus controllers (74) | +| `app/frontend/javascript/controllers/` | Stimulus controllers (75) | | `app/frontend/javascript/rhino/` | Rich text editor customizations (mentions, grid) | | `app/frontend/stylesheets/` | Tailwind CSS and component styles | @@ -266,6 +266,7 @@ end ### Stimulus Controllers +- `address_region` — Toggle an address's state field between a US state dropdown and a free-text region input based on the selected country - `address_select` — Compact numbered picker linking an affiliation to an org address - `affiliation_dates` — Recalculate affiliation date ranges - `anchor_highlight` — Highlight anchored elements diff --git a/app/frontend/javascript/controllers/address_region_controller.js b/app/frontend/javascript/controllers/address_region_controller.js new file mode 100644 index 0000000000..3424669e1b --- /dev/null +++ b/app/frontend/javascript/controllers/address_region_controller.js @@ -0,0 +1,29 @@ +import { Controller } from "@hotwired/stimulus" + +// Toggles an address's state field between a US state dropdown (validated server +// side) and a free-text region input, based on the selected country. Only the +// active input stays enabled, so exactly one value submits under the shared +// `[state]` name. Each address block scopes its own controller instance, so this +// works for cocoon-added addresses too. +export default class extends Controller { + static targets = ["country", "stateSelect", "stateText"] + static values = { usCountry: String } + + connect() { + this.update() + } + + update() { + const isUs = this.countryTarget.value === "" || this.countryTarget.value === this.usCountryValue + this.activate(this.stateSelectTarget, isUs) + this.activate(this.stateTextTarget, !isUs) + } + + // Show and enable the active field (and its wrapper), hide and disable the other + // so it neither submits nor blocks form validation while hidden. + activate(field, active) { + field.disabled = !active + const wrapper = field.closest("[data-address-region-wrapper]") || field + wrapper.classList.toggle("hidden", !active) + } +} diff --git a/app/frontend/javascript/controllers/index.js b/app/frontend/javascript/controllers/index.js index 62b3cebdea..3eb53efc96 100644 --- a/app/frontend/javascript/controllers/index.js +++ b/app/frontend/javascript/controllers/index.js @@ -1,5 +1,8 @@ import { application } from "./application" +import AddressRegionController from "./address_region_controller" +application.register("address-region", AddressRegionController) + import AddressSelectController from "./address_select_controller" application.register("address-select", AddressSelectController) diff --git a/app/helpers/geography_helper.rb b/app/helpers/geography_helper.rb index 94ed074545..d7ce3f879f 100644 --- a/app/helpers/geography_helper.rb +++ b/app/helpers/geography_helper.rb @@ -1,57 +1,26 @@ module GeographyHelper def us_states - [ - [ "Alabama", "AL" ], - [ "Alaska", "AK" ], - [ "Arizona", "AZ" ], - [ "Arkansas", "AR" ], - [ "California", "CA" ], - [ "Colorado", "CO" ], - [ "Connecticut", "CT" ], - [ "Delaware", "DE" ], - [ "District of Columbia", "DC" ], - [ "Florida", "FL" ], - [ "Georgia", "GA" ], - [ "Hawaii", "HI" ], - [ "Idaho", "ID" ], - [ "Illinois", "IL" ], - [ "Indiana", "IN" ], - [ "Iowa", "IA" ], - [ "Kansas", "KS" ], - [ "Kentucky", "KY" ], - [ "Louisiana", "LA" ], - [ "Maine", "ME" ], - [ "Maryland", "MD" ], - [ "Massachusetts", "MA" ], - [ "Michigan", "MI" ], - [ "Minnesota", "MN" ], - [ "Mississippi", "MS" ], - [ "Missouri", "MO" ], - [ "Montana", "MT" ], - [ "Nebraska", "NE" ], - [ "Nevada", "NV" ], - [ "New Hampshire", "NH" ], - [ "New Jersey", "NJ" ], - [ "New Mexico", "NM" ], - [ "New York", "NY" ], - [ "North Carolina", "NC" ], - [ "North Dakota", "ND" ], - [ "Ohio", "OH" ], - [ "Oklahoma", "OK" ], - [ "Oregon", "OR" ], - [ "Pennsylvania", "PA" ], - [ "Rhode Island", "RI" ], - [ "South Carolina", "SC" ], - [ "South Dakota", "SD" ], - [ "Tennessee", "TN" ], - [ "Texas", "TX" ], - [ "Utah", "UT" ], - [ "Vermont", "VT" ], - [ "Virginia", "VA" ], - [ "Washington", "WA" ], - [ "West Virginia", "WV" ], - [ "Wisconsin", "WI" ], - [ "Wyoming", "WY" ] - ] + UsState::ALL + end + + # A of US states for a free-text state/region input: it suggests US + # states (stored as abbreviations) while still letting the user type any + # international region or leave it blank. Reference it from an input via + # `list:` matching the given +id+. + def us_states_datalist(id) + options = us_states.map { |name, abbr| tag.option(value: abbr, label: name) } + tag.datalist(safe_join(options), id: id) + end + + # Country names for the address country dropdown: the canonical "United States" + # first (it drives the US-state-vs-region toggle), then every other country + # alphabetically. The US is excluded from the gem list by code so the only US + # option is our canonical value, not the gem's verbose "United States of America". + def country_options + others = ISO3166::Country.all + .reject { |country| country.alpha2 == "US" } + .map(&:iso_short_name) + .sort + [ Address::US_COUNTRY ] + others end end diff --git a/app/models/address.rb b/app/models/address.rb index 22fed57341..d4593d5a76 100644 --- a/app/models/address.rb +++ b/app/models/address.rb @@ -3,6 +3,11 @@ class Address < ApplicationRecord "Central CA", "Orange County", "Outside CA", "Outside USA", "Unknown" ] CONTACT_TYPES = [ nil, "work", "personal", "mailing", "unknown" ].freeze + # The canonical country value that flags an address as domestic. It drives the + # form's US-state-dropdown vs. free-text-region toggle and the state validation + # below, so the dropdown option and this check share one constant. + US_COUNTRY = "United States".freeze + belongs_to :addressable, polymorphic: true, touch: true # Affiliations that point to this address as their organization address. Nullify # the link rather than block deletion when an org address is removed. @@ -11,11 +16,34 @@ class Address < ApplicationRecord validates :locality, presence: true validates :city, presence: true validates :state, presence: true + # US addresses must use a recognized state abbreviation; international addresses + # store a free-form region, so they only need a value (presence above). The check + # is case-insensitive to tolerate legacy lowercase abbreviations (e.g. "tx"). + validate :state_is_a_us_state, if: :united_states? validates :address_type, inclusion: { in: CONTACT_TYPES } scope :active, -> { where(inactive: false) } + # A blank or "United States" country is treated as domestic; any other country + # is international and exempt from the US-state-abbreviation check. + def united_states? + country.blank? || country == US_COUNTRY + end + + # US states are stored as abbreviations (e.g. "CA"); international addresses may + # store a free-form region (e.g. "Ontario"). A blank state is dropped so the + # rendered name never carries a stray separator. def name - "#{street_address}, #{city}, #{state} #{zip_code}" + region = [ state, zip_code ].compact_blank.join(" ") + [ street_address, city, region ].compact_blank.join(", ") + end + + private + + def state_is_a_us_state + return if state.blank? + return if UsState::ABBREVIATIONS.include?(state.upcase) + + errors.add(:state, "is not a valid US state") end end diff --git a/app/models/us_state.rb b/app/models/us_state.rb new file mode 100644 index 0000000000..0d67a403ae --- /dev/null +++ b/app/models/us_state.rb @@ -0,0 +1,60 @@ +# Canonical list of US states (plus DC) shared by the address form dropdown and +# the Address state-inclusion validation, so the options offered and the values +# accepted can never drift apart. +module UsState + ALL = [ + [ "Alabama", "AL" ], + [ "Alaska", "AK" ], + [ "Arizona", "AZ" ], + [ "Arkansas", "AR" ], + [ "California", "CA" ], + [ "Colorado", "CO" ], + [ "Connecticut", "CT" ], + [ "Delaware", "DE" ], + [ "District of Columbia", "DC" ], + [ "Florida", "FL" ], + [ "Georgia", "GA" ], + [ "Hawaii", "HI" ], + [ "Idaho", "ID" ], + [ "Illinois", "IL" ], + [ "Indiana", "IN" ], + [ "Iowa", "IA" ], + [ "Kansas", "KS" ], + [ "Kentucky", "KY" ], + [ "Louisiana", "LA" ], + [ "Maine", "ME" ], + [ "Maryland", "MD" ], + [ "Massachusetts", "MA" ], + [ "Michigan", "MI" ], + [ "Minnesota", "MN" ], + [ "Mississippi", "MS" ], + [ "Missouri", "MO" ], + [ "Montana", "MT" ], + [ "Nebraska", "NE" ], + [ "Nevada", "NV" ], + [ "New Hampshire", "NH" ], + [ "New Jersey", "NJ" ], + [ "New Mexico", "NM" ], + [ "New York", "NY" ], + [ "North Carolina", "NC" ], + [ "North Dakota", "ND" ], + [ "Ohio", "OH" ], + [ "Oklahoma", "OK" ], + [ "Oregon", "OR" ], + [ "Pennsylvania", "PA" ], + [ "Rhode Island", "RI" ], + [ "South Carolina", "SC" ], + [ "South Dakota", "SD" ], + [ "Tennessee", "TN" ], + [ "Texas", "TX" ], + [ "Utah", "UT" ], + [ "Vermont", "VT" ], + [ "Virginia", "VA" ], + [ "Washington", "WA" ], + [ "West Virginia", "WV" ], + [ "Wisconsin", "WI" ], + [ "Wyoming", "WY" ] + ].freeze + + ABBREVIATIONS = ALL.map(&:last).freeze +end diff --git a/app/services/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index ff7c3e3e82..3a50e7391c 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -169,6 +169,7 @@ def find_matching_person(last_name:, email:) def create_mailing_address(person) new_city = field_value("mailing_city")&.strip new_state = field_value("mailing_state")&.strip + new_country = field_value("mailing_country")&.strip.presence existing = person.addresses.find_by( "LOWER(city) = ? AND LOWER(COALESCE(state, '')) = ?", @@ -179,6 +180,7 @@ def create_mailing_address(person) existing.update!( street_address: field_value("mailing_street"), zip_code: field_value("mailing_zip"), + country: new_country, primary: true, inactive: false ) @@ -191,6 +193,7 @@ def create_mailing_address(person) street_address: field_value("mailing_street"), city: new_city, state: new_state, + country: new_country, zip_code: field_value("mailing_zip"), locality: "Unknown", address_type: field_value("mailing_address_type")&.downcase || "unknown", @@ -243,6 +246,7 @@ def create_affiliation(person, organization) def create_agency_address(organization) new_city = field_value("agency_city")&.strip new_state = field_value("agency_state")&.strip + new_country = field_value("agency_country")&.strip.presence existing = organization.addresses.find_by( "LOWER(city) = ? AND LOWER(COALESCE(state, '')) = ?", @@ -253,6 +257,7 @@ def create_agency_address(organization) existing.update!( street_address: field_value("agency_street"), zip_code: field_value("agency_zip"), + country: new_country, primary: true, inactive: false ) @@ -265,6 +270,7 @@ def create_agency_address(organization) street_address: field_value("agency_street"), city: new_city, state: new_state, + country: new_country, zip_code: field_value("agency_zip"), locality: "Unknown", address_type: "work", diff --git a/app/views/addresses/_address_fields.html.erb b/app/views/addresses/_address_fields.html.erb index 23819503f4..dc96a55520 100644 --- a/app/views/addresses/_address_fields.html.erb +++ b/app/views/addresses/_address_fields.html.erb @@ -1,4 +1,6 @@ -
+
Address <%= "#" + (f.index.to_i + 1).to_s if f.object.persisted? %><%= ": " + f.object.name if f.object.persisted? %>
@@ -89,12 +91,32 @@ class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %> - <%= f.input :state, - as: :select, - collection: us_states, - input_html: { - class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" - } %> + <%# State: a validated US dropdown for domestic addresses, a free-text region + input for international ones. The address-region controller shows whichever + matches the selected country; the hidden one is disabled so it never submits. %> + <% domestic = f.object.new_record? || f.object.united_states? %> +
+
"> + <%= f.label :state, "State", class: "block text-sm font-medium text-gray-700" %> + <%= f.select :state, + us_states, + { include_blank: "Select a state" }, + required: true, + disabled: !domestic, + data: { address_region_target: "stateSelect" }, + class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" %> +
+
"> + <%= f.label :state, "State / region", for: f.field_id(:state, :region), + class: "block text-sm font-medium text-gray-700" %> + <%= f.text_field :state, + id: f.field_id(:state, :region), + required: true, + disabled: domestic, + data: { address_region_target: "stateText" }, + class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" %> +
+
<%= f.input :zip_code, label: "ZIP Code", input_html: { @@ -116,11 +138,17 @@ class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %> - <%= f.input :country, - label: "Country", - input_html: { - class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" - } %> + <%# Country drives the state field above: choosing anything other than the + United States swaps the state dropdown for a free-text region input. %> +
+ <%= f.label :country, "Country", class: "block text-sm font-medium text-gray-700" %> + <%= f.select :country, + country_options, + { selected: f.object.country.presence || Address::US_COUNTRY }, + required: true, + data: { address_region_target: "country", action: "address-region#update" }, + class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" %> +
<%= f.input :phone, label: "Phone", input_html: { diff --git a/app/views/events/public_registrations/_form_field.html.erb b/app/views/events/public_registrations/_form_field.html.erb index a63030e1b5..fb72cd1158 100644 --- a/app/views/events/public_registrations/_form_field.html.erb +++ b/app/views/events/public_registrations/_form_field.html.erb @@ -34,15 +34,16 @@ <% case field.answer_type %> <% when "free_form_input_one_line" %> <% if field.field_identifier&.end_with?("_state") %> - + <%# Free-text so international registrants can enter any region, with US + states offered as datalist suggestions (stored as abbreviations). %> + > + <%= us_states_datalist("#{field_id}_states") %> <% else %> <% html_type = case field.field_identifier diff --git a/app/views/organizations/_address_fields.html.erb b/app/views/organizations/_address_fields.html.erb index 228ab45e9e..b59a5d821d 100644 --- a/app/views/organizations/_address_fields.html.erb +++ b/app/views/organizations/_address_fields.html.erb @@ -1,4 +1,6 @@ -
+
Address <%= "#" + (f.index.to_i + 1).to_s if f.object.persisted? %><%= ": " + f.object.name if f.object.persisted? %>
@@ -93,11 +95,32 @@ class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %> - <%= f.input :state, - label: "State", - input_html: { - class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" - } %> + <%# State: a validated US dropdown for domestic addresses, a free-text region + input for international ones. The address-region controller shows whichever + matches the selected country; the hidden one is disabled so it never submits. %> + <% domestic = f.object.new_record? || f.object.united_states? %> +
+
"> + <%= f.label :state, "State", class: "block text-sm font-medium text-gray-700" %> + <%= f.select :state, + us_states, + { include_blank: "Select a state" }, + required: true, + disabled: !domestic, + data: { address_region_target: "stateSelect" }, + class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" %> +
+
"> + <%= f.label :state, "State / region", for: f.field_id(:state, :region), + class: "block text-sm font-medium text-gray-700" %> + <%= f.text_field :state, + id: f.field_id(:state, :region), + required: true, + disabled: domestic, + data: { address_region_target: "stateText" }, + class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" %> +
+
<%= f.input :zip_code, label: "ZIP Code", input_html: { @@ -119,11 +142,17 @@ class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %> - <%= f.input :country, - label: "Country", - input_html: { - class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" - } %> + <%# Country drives the state field above: choosing anything other than the + United States swaps the state dropdown for a free-text region input. %> +
+ <%= f.label :country, "Country", class: "block text-sm font-medium text-gray-700" %> + <%= f.select :country, + country_options, + { selected: f.object.country.presence || Address::US_COUNTRY }, + required: true, + data: { address_region_target: "country", action: "address-region#update" }, + class: "block w-full rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" %> +
<%= f.input :phone, label: "Phone", input_html: { diff --git a/spec/models/address_spec.rb b/spec/models/address_spec.rb index f257b4fb70..0f936b46a3 100644 --- a/spec/models/address_spec.rb +++ b/spec/models/address_spec.rb @@ -66,4 +66,48 @@ expect(address).to be_valid end end + + describe "state by country" do + it "treats a blank or United States country as domestic" do + expect(build(:address, country: nil)).to be_united_states + expect(build(:address, country: "United States")).to be_united_states + expect(build(:address, country: "Canada")).not_to be_united_states + end + + it "accepts a recognized US state abbreviation for a domestic address" do + address = build(:address, country: "United States", state: "CA") + expect(address).to be_valid + end + + it "rejects an unrecognized state for a domestic address" do + address = build(:address, country: "United States", state: "Ontario") + expect(address).not_to be_valid + expect(address.errors[:state]).to include("is not a valid US state") + end + + it "accepts a free-form region for an international address" do + address = build(:address, country: "Canada", state: "Ontario") + expect(address).to be_valid + end + + it "still requires a state for an international address" do + address = build(:address, country: "Canada", state: nil) + expect(address).not_to be_valid + expect(address.errors[:state]).to include("can't be blank") + end + end + + describe "#name" do + it "formats a US address with a state" do + address = build(:address, street_address: "123 Main St", city: "Los Angeles", + state: "CA", zip_code: "90001") + expect(address.name).to eq("123 Main St, Los Angeles, CA 90001") + end + + it "omits a blank state without leaving a stray separator" do + address = build(:address, street_address: "10 King St W", city: "Toronto", + state: nil, zip_code: "M5H 1B6") + expect(address.name).to eq("10 King St W, Toronto, M5H 1B6") + end + end end diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 7f9eb316a2..7f31365427 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -121,6 +121,37 @@ def register_with(position:) end end + describe "international vs. US mailing address" do + def register_with(state:, country:) + params = base_form_params(first_name: "Robin", last_name: "Avery", email: "robin@example.com").merge( + field_id("mailing_street") => "123 Main St", + field_id("mailing_city") => "Toronto", + field_id("mailing_state") => state, + field_id("mailing_zip") => "M5H 1B6", + field_id("mailing_country") => country + ) + described_class.call(event: event, form: form, form_params: params) + end + + it "saves a free-form region and country for an international address" do + result = register_with(state: "Ontario", country: "Canada") + + expect(result.success?).to be true + address = Person.find_by(first_name: "Robin").addresses.find_by(primary: true) + expect(address).to have_attributes(state: "Ontario", country: "Canada") + end + + it "rejects an unrecognized state when the country is the United States" do + result = nil + expect { + result = register_with(state: "Ontario", country: "United States") + }.not_to change(EventRegistration, :count) + + expect(result.success?).to be false + expect(result.errors.join).to match(/state.*not a valid US state/i) + end + end + describe "sector tagging" do let!(:primary_sector) { create(:sector, name: "Healthcare") } let!(:additional_sector) { create(:sector, name: "Education") } diff --git a/spec/system/public_registration_form_submission_spec.rb b/spec/system/public_registration_form_submission_spec.rb index ac7c04f389..5919f3354d 100644 --- a/spec/system/public_registration_form_submission_spec.rb +++ b/spec/system/public_registration_form_submission_spec.rb @@ -374,7 +374,7 @@ def fill_full_registration fill_pr_text reg_field("mailing_street"), with: "123 Main St" choose_pr_radio reg_field("mailing_address_type"), "Personal" fill_pr_text reg_field("mailing_city"), with: "Los Angeles" - select_pr reg_field("mailing_state"), "California (CA)" + fill_pr_text reg_field("mailing_state"), with: "CA" fill_pr_text reg_field("mailing_zip"), with: "90001" fill_pr_text reg_field("mailing_country"), with: "United States" fill_pr_text reg_field("phone"), with: "555-123-4567" @@ -386,7 +386,7 @@ def fill_full_registration choose_pr_radio reg_field("agency_type"), "501c3/nonprofit" fill_pr_text reg_field("agency_street"), with: "9 Center Ave" fill_pr_text reg_field("agency_city"), with: "Pasadena" - select_pr reg_field("agency_state"), "California (CA)" + fill_pr_text reg_field("agency_state"), with: "CA" fill_pr_text reg_field("agency_zip"), with: "91101" fill_pr_text reg_field("agency_country"), with: "United States"