Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions app/frontend/javascript/controllers/address_region_controller.js
Original file line number Diff line number Diff line change
@@ -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)
}
}
3 changes: 3 additions & 0 deletions app/frontend/javascript/controllers/index.js
Original file line number Diff line number Diff line change
@@ -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)

Expand Down
75 changes: 22 additions & 53 deletions app/helpers/geography_helper.rb
Original file line number Diff line number Diff line change
@@ -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 <datalist> 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
30 changes: 29 additions & 1 deletion app/models/address.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: The US-state check only runs when united_states? (blank/US country). International addresses just need a value, so a region like "Ontario" passes. The check itself is case-insensitive (see state_is_a_us_state) to tolerate legacy lowercase abbreviations the dashboard already normalizes.

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
60 changes: 60 additions & 0 deletions app/models/us_state.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: The form already collected mailing_country but the service never saved it. That gap is load-bearing now: without a country, an international address defaults to domestic and the new US-state validation would reject the region. Persisting it (also for agency_country) is what lets non-US registrations through.


existing = person.addresses.find_by(
"LOWER(city) = ? AND LOWER(COALESCE(state, '')) = ?",
Expand All @@ -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
)
Expand All @@ -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",
Expand Down Expand Up @@ -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, '')) = ?",
Expand All @@ -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
)
Expand All @@ -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",
Expand Down
52 changes: 40 additions & 12 deletions app/views/addresses/_address_fields.html.erb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<div class="nested-fields rounded-lg bg-white border border-gray-200 p-6 mb-4">
<div class="nested-fields rounded-lg bg-white border border-gray-200 p-6 mb-4"
data-controller="address-region"
data-address-region-us-country-value="<%= Address::US_COUNTRY %>">
<div class="text-lg font-semibold text-gray-800 mb-4">
Address <%= "#" + (f.index.to_i + 1).to_s if f.object.persisted? %><%= ": " + f.object.name if f.object.persisted? %>
</div>
Expand Down Expand Up @@ -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? %>
<div>
<div data-address-region-wrapper class="<%= "hidden" unless domestic %>">
<%= 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" %>
</div>
<div data-address-region-wrapper class="<%= "hidden" if domestic %>">
<%= f.label :state, "State / region", for: f.field_id(:state, :region),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 From Claude: Both state inputs bind to the same state attribute, so the inactive one is disabled (not just hidden) — disabled fields do not submit, so exactly one value posts. Initial visibility is server-rendered from the stored country so there is no flash before the Stimulus controller connects.

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" %>
</div>
</div>
<%= f.input :zip_code,
label: "ZIP Code",
input_html: {
Expand All @@ -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. %>
<div>
<%= 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" %>
</div>
<%= f.input :phone,
label: "Phone",
input_html: {
Expand Down
19 changes: 10 additions & 9 deletions app/views/events/public_registrations/_form_field.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,16 @@
<% case field.answer_type %>
<% when "free_form_input_one_line" %>
<% if field.field_identifier&.end_with?("_state") %>
<select name="<%= field_name %>"
id="<%= field_id %>"
class="<%= input_classes %>"
<%= "required" if field.required %>>
<option value="">Select a state</option>
<% us_states.each do |name, abbr| %>
<option value="<%= abbr %>" <%= "selected" if value == abbr %>><%= name %> (<%= abbr %>)</option>
<% end %>
</select>
<%# Free-text so international registrants can enter any region, with US
states offered as datalist suggestions (stored as abbreviations). %>
<input type="text"
name="<%= field_name %>"
id="<%= field_id %>"
value="<%= value %>"
list="<%= field_id %>_states"
class="<%= input_classes %>"
<%= "required" if field.required %>>
<%= us_states_datalist("#{field_id}_states") %>
<% else %>
<%
html_type = case field.field_identifier
Expand Down
Loading