From 39f5cf560d7748f90baf23cd82140f21f4aa49ff Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 08:42:12 -0400 Subject: [PATCH 01/13] Let license issuing states and form states be typed outside the USA Co-Authored-By: Claude Opus 5.5 (1M context) From 11ce62d210765a86af36c47371fe79eaa3646bd8 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 08:57:46 -0400 Subject: [PATCH 02/13] Let licenses and form states be entered from outside the USA, without JS A registrant licensed in Ontario had no way to say so: every state field was a US-only dropdown, and a saved non-US value rendered blank so the next save wiped it. An "Outside USA?"
box beside each dropdown takes a typed state and wins when filled, so nothing a person already entered is lost. Licenses also record an issuing country, since a province alone doesn't place a license. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 8 +- ...uing_education_registrations_controller.rb | 4 +- app/controllers/events/callouts_controller.rb | 5 +- .../events/public_registrations_controller.rb | 2 +- app/controllers/organizations_controller.rb | 1 + .../professional_licenses_controller.rb | 4 +- .../profile_change_requests_controller.rb | 2 +- app/controllers/public_forms_controller.rb | 2 +- .../professional_license_decorator.rb | 15 +- .../ce_license_picker_controller.js | 16 ++- app/helpers/geography_helper.rb | 11 +- app/models/address.rb | 5 + app/models/concerns/outside_usa_typeable.rb | 22 +++ .../continuing_education_registration.rb | 10 +- app/models/person.rb | 2 +- app/models/professional_license.rb | 4 + app/models/profile_change_request.rb | 4 + app/policies/person_policy.rb | 3 +- app/services/outside_usa_state.rb | 25 ++++ app/views/addresses/_state_field.html.erb | 29 ++-- .../_details_section.html.erb | 15 +- .../show.html.erb | 2 +- app/views/events/callouts/ce.html.erb | 15 +- .../_form_field_input.html.erb | 16 ++- app/views/people/_admin_details.html.erb | 7 +- .../professional_licenses/_form.html.erb | 15 +- .../_professional_license_fields.html.erb | 22 ++- .../professional_licenses_results.html.erb | 2 +- .../_work_address_fields.html.erb | 11 +- app/views/shared/_outside_usa.html.erb | 7 + ...ssuing_country_to_professional_licenses.rb | 9 ++ db/schema.rb | 3 +- .../2026-10-25-outside-usa-license-states.yml | 12 ++ .../professional_license_decorator_spec.rb | 24 ++++ spec/models/professional_license_spec.rb | 13 ++ spec/models/profile_change_request_spec.rb | 6 + spec/requests/address_state_field_spec.rb | 19 +++ spec/requests/outside_usa_state_spec.rb | 136 ++++++++++++++++++ spec/services/outside_usa_state_spec.rb | 43 ++++++ 39 files changed, 499 insertions(+), 52 deletions(-) create mode 100644 app/models/concerns/outside_usa_typeable.rb create mode 100644 app/services/outside_usa_state.rb create mode 100644 app/views/shared/_outside_usa.html.erb create mode 100644 db/migrate/20261025125134_add_issuing_country_to_professional_licenses.rb create mode 100644 db/seeds/features/2026-10-25-outside-usa-license-states.yml create mode 100644 spec/requests/outside_usa_state_spec.rb create mode 100644 spec/services/outside_usa_state_spec.rb diff --git a/AGENTS.md b/AGENTS.md index a2b06ae67b..368cea96eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,9 +49,9 @@ This codebase (Rails 8.1) | Directory | Purpose | Count | |---|---|---| | `app/models/` | ActiveRecord models | ~146 files | -| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for the stories spreadsheet import) | ~152 files | +| `app/services/` | Service objects and POROs (e.g. `MoneyFormatter` for currency display, `StoryImporter` for the stories spreadsheet import) | ~153 files | | `app/jobs/` | SolidQueue background jobs | 6 files | -| `app/models/concerns/` | Shared model modules | 23 concerns | +| `app/models/concerns/` | Shared model modules | 25 concerns | ### Presentation @@ -152,6 +152,7 @@ This codebase (Rails 8.1) | `FilemakerCoded` | Reads `filemaker_code` as the trimmed, comma-separated **list** it is (Person, Organization, Affiliation, Payment, Workshop, Resource): `#filemaker_codes` for the set a record answers to, `.join_filemaker_codes` to combine any mix of strings/lists into one normalized column value (both dedupers union the codes on merge). `filemaker_archived_in` names the `Fm*` archive table(s) a model's codes point into (`.filemaker_archives`), and `.with_filemaker_code(code)` finds records carrying a code. Nothing should compare the whole column against a single code — `FilemakerHelper#filemaker_code_links` renders one archive link per code, and `fm_archives#show` links back to the portal records carrying its code | | `Mentionable` | ActionText mention grouping, plus `connected_records(klass)` — records linked by a mention in either direction (the only Resource↔Workshop link: `Resource#connected_workshops`, `Workshop#connected_resources`) | | `NameFilterable` | Name-based filtering | +| `OutsideUsaTypeable` | `typed_outside_usa :state` adds the `_outside_usa` box `shared/_outside_usa` reveals beside a US-state dropdown; a typed value wins whichever is assigned first (ProfessionalLicense, Address, ProfileChangeRequest) | | `PayChargeExtensions` | Included into `Pay::Charge` (via `config/initializers/pay.rb`): turns a Stripe charge into an AWBW `Payment` + `Allocation`, resolving the membership invoice it pays | | `PaySubscriptionExtensions` | Included into `Pay::Subscription`: adds `active_uncanceled` — Pay's own `active` scope still counts a canceled subscription riding out its grace period, so this narrows to the ones that will keep billing | | `Publishable` | `published`, `publicly_visible` scopes | @@ -273,6 +274,7 @@ comparison — add a rule if none fits (e.g. `PersonPolicy#own_profile?`). Conve - `FmAddressAttributes` — Turns an `FmAddress` row into `Address` attributes for any FileMaker import (street from `StreetNo` + lines, ZIP or its `PostalCodeLookup`, county from the archive's ZIP table read once, everything clipped to 255), with state/country/type/locality through `AddressTranslation`; nil when the row lacks a street, city or state. An `address_type:` overrides the row's (an organization's addresses are work); `#place(city:, state:, county:)` builds a street-less address for a place the archive records by city alone (a project). `#unmatched` gathers what it couldn't translate. Used by `PersonServices::ImportFromRolodex` (listed in `fm:import_people`'s report) and the FileMaker organizations/affiliations imports (ADR-0008) - `AddressDefaults` — The country, county and locality an address leaves blank, from its other parts: a US state → United States (`Address::DEFAULT_COUNTRY`), a US ZIP → its county (`FmPostalCode.county_for`, FileMaker's ZIP table, skipped when the ZIP is in another state), then `AddressLocality`. Used on create by `OrganizationServices::UpsertAddress` (both org-linking flows and public registration capture), `PersonServices::CaptureFromSubmission` and `ProfileChangeRequests::Apply`; `UpsertAddress` also fills a matched address's blank country/county and blank or Unknown locality, never replacing a value someone set - `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`, and through `AddressDefaults` for the rest — 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 mailing city inside the City of Los Angeles — Los Angeles or a neighborhood (`LA_CITY_MAILING_CITIES`) — as **LA City**, the rest of Los Angeles County as **LA County**, Orange County as **Orange County**, and any other California county by `CALIFORNIA_REGIONS` (the region most FileMaker projects in that county name, plus a hand-checked few) as **Northern/Central/Southern CA**. A state no US state answers to, with no country, is **Outside USA** (the forms only offer US states). Everything else stays **Unknown**: a California address with no or an unplaced county, and a US address whose state no US state answers to. 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 +- `OutsideUsaState` — Resolves a US-state dropdown + "Outside USA?" box pair: `.pick(dropdown, typed)` for raw params (CE callout, admin CE registration) and `.form_field_answers(params, scope)`, which lays `form_fields_outside_usa` answers over `form_fields` for every `_state` question (public forms, registration, callout forms). The box itself is `shared/_outside_usa` — a no-JS `
` - `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 - `PersonServices::ReconcilePrimaryDesignations` — After a person merge, keeps the survivor's own primary sector + primary age range and demotes the duplicate's, which would otherwise leave the kept person with two primaries and trip Person's single-primary validations. The survivor's pre-merge primary ids are captured in the people deduper's `merge_keeper` (they're indistinguishable once merged); reconciliation runs in `after_merge` @@ -437,7 +439,7 @@ end - `asset_picker` — Asset selection UI - `autosave` — Auto-save form state; optional `status` targets (`data-field-name` = the field's name) show "Saved" or "Not saved: " beside a field via `shared/_autosave_status` (dedupe preview, index notes, notification responded toggles) - `carousel` — Swiper-based carousels (opt-in `Autoplay` via the options value) -- `ce_license_picker` — Fill the CE license type/number/state/expiry fields from the picked license (or clear them for a new one) +- `ce_license_picker` — Fill the CE license type/number/state/country/expiry fields from the picked license (or clear them for a new one), opening the "Outside USA?" box for a license issued abroad - `cocoon` — Nested form handling (cocoon gem) - `collection` — Filter form auto-submit with debounce - `column_toggle` — Toggle table column visibility diff --git a/app/controllers/continuing_education_registrations_controller.rb b/app/controllers/continuing_education_registrations_controller.rb index 3a39a33a1b..3470c49154 100644 --- a/app/controllers/continuing_education_registrations_controller.rb +++ b/app/controllers/continuing_education_registrations_controller.rb @@ -106,7 +106,9 @@ def license_for_create def apply_ce_params(ce_registration) ce_registration.assign_license(number: params.dig(:continuing_education_registration, :license_number), kind: params.dig(:continuing_education_registration, :license_kind), - issuing_state: params.dig(:continuing_education_registration, :license_issuing_state), + issuing_state: OutsideUsaState.pick(params.dig(:continuing_education_registration, :license_issuing_state), + params.dig(:continuing_education_registration, :license_issuing_state_outside_usa)), + issuing_country: params.dig(:continuing_education_registration, :license_issuing_country), expires_on: params.dig(:continuing_education_registration, :license_expires_on), license_id: params.dig(:continuing_education_registration, :professional_license_id)) ce_registration.hours = params.dig(:continuing_education_registration, :hours) diff --git a/app/controllers/events/callouts_controller.rb b/app/controllers/events/callouts_controller.rb index dc602e7aee..2d484aa60b 100644 --- a/app/controllers/events/callouts_controller.rb +++ b/app/controllers/events/callouts_controller.rb @@ -194,7 +194,8 @@ def update_ce_license end ce_registration.assign_license(number: params[:license_number], kind: params[:license_kind], - issuing_state: params[:license_issuing_state], expires_on: params[:license_expires_on]) + issuing_state: OutsideUsaState.pick(params[:license_issuing_state], params[:license_issuing_state_outside_usa]), + issuing_country: params[:license_issuing_country], expires_on: params[:license_expires_on]) ce_registration.save! # assign_license already normalized the number; mirror the saved value rather # than re-stripping the raw param. @@ -456,7 +457,7 @@ def ce_form_locked?(callout) end def callout_form_params - params.dig(:callout_form, :form_fields)&.to_unsafe_h || {} + OutsideUsaState.form_field_answers(params, :callout_form) end # Attendance sign-in/out follows the CE payment — it's the CE sign-in sheet. Any-of diff --git a/app/controllers/events/public_registrations_controller.rb b/app/controllers/events/public_registrations_controller.rb index abe620f9d8..67c9866244 100644 --- a/app/controllers/events/public_registrations_controller.rb +++ b/app/controllers/events/public_registrations_controller.rb @@ -36,7 +36,7 @@ def create @scholarship_form = @event.scholarship_form if @scholarship @continuing_education_form = @event.continuing_education_form - all_params = merge_retained_uploads(params.dig(:public_registration, :form_fields)&.to_unsafe_h || {}) + all_params = merge_retained_uploads(OutsideUsaState.form_field_answers(params, :public_registration)) registration_params, scholarship_params, continuing_education_params = split_form_params(all_params) @field_errors = validate_required_fields(registration_params) diff --git a/app/controllers/organizations_controller.rb b/app/controllers/organizations_controller.rb index 5aac647ab7..760f51e7b4 100644 --- a/app/controllers/organizations_controller.rb +++ b/app/controllers/organizations_controller.rb @@ -248,6 +248,7 @@ def organization_params :street_address, :city, :state, + :state_outside_usa, :zip_code, :county, :country, diff --git a/app/controllers/professional_licenses_controller.rb b/app/controllers/professional_licenses_controller.rb index e28534e53e..346324fa7c 100644 --- a/app/controllers/professional_licenses_controller.rb +++ b/app/controllers/professional_licenses_controller.rb @@ -49,12 +49,12 @@ def update private def professional_license_params - params.require(:professional_license).permit(:person_id, :number, :kind, :issuing_state, :expires_on) + params.require(:professional_license).permit(:person_id, :number, :kind, :issuing_state, :issuing_state_outside_usa, :issuing_country, :expires_on) end # The registrant is fixed once a license exists — edits change the license # fields only, never who it belongs to. def license_edit_params - params.require(:professional_license).permit(:number, :kind, :issuing_state, :expires_on) + params.require(:professional_license).permit(:number, :kind, :issuing_state, :issuing_state_outside_usa, :issuing_country, :expires_on) end end diff --git a/app/controllers/profile_change_requests_controller.rb b/app/controllers/profile_change_requests_controller.rb index a72776eff5..1bcfd3c172 100644 --- a/app/controllers/profile_change_requests_controller.rb +++ b/app/controllers/profile_change_requests_controller.rb @@ -155,7 +155,7 @@ def profile_change_request_params params.require(:profile_change_request).permit( :field, :requested_value, :details, :affiliation_id, :organization_id, :professional_license_id, :proposed_email_type, :proposed_title, :proposed_start_date, :proposed_end_date, :proposed_organization_name, :proposed_organization_address_id, - :proposed_street_address, :proposed_city, :proposed_state, :proposed_zip_code, :leaving_job, left_organization_ids: [] + :proposed_street_address, :proposed_city, :proposed_state, :proposed_state_outside_usa, :proposed_zip_code, :leaving_job, left_organization_ids: [] ) end diff --git a/app/controllers/public_forms_controller.rb b/app/controllers/public_forms_controller.rb index 33abda6581..1ab7e19a9d 100644 --- a/app/controllers/public_forms_controller.rb +++ b/app/controllers/public_forms_controller.rb @@ -24,7 +24,7 @@ def create @form_fields = public_form_fields.visible @prefill_values = {} - form_params = merge_retained_uploads(params.dig(:public_registration, :form_fields)&.to_unsafe_h || {}) + form_params = merge_retained_uploads(OutsideUsaState.form_field_answers(params, :public_registration)) @field_errors = validate_required_fields(form_params) if @field_errors.any? diff --git a/app/decorators/professional_license_decorator.rb b/app/decorators/professional_license_decorator.rb index d52598d707..479b8adbf7 100644 --- a/app/decorators/professional_license_decorator.rb +++ b/app/decorators/professional_license_decorator.rb @@ -4,7 +4,16 @@ class ProfessionalLicenseDecorator < ApplicationDecorator # One line identifying the license, e.g. "LMFT · 12345 · CA · expires June 30, 2027". def summary expires = "expires #{expires_on.to_fs(:long)}" if expires_on - [ kind, number, issuing_state, expires ].compact_blank.join(" · ") + [ kind, number, issuing_place, expires ].compact_blank.join(" · ") + end + + # "CA", or "Ontario, Canada" for a license issued outside the USA. + def issuing_place + [ issuing_state, (issuing_country if foreign_country?) ].compact_blank.join(", ") + end + + def outside_usa? + h.outside_usa_state?(issuing_state) || foreign_country? end # Status pill combining validity + expiry date: expired on / expiring soon / @@ -44,6 +53,10 @@ def ce_hours_issued_all_years private + def foreign_country? + issuing_country.present? && issuing_country != Address::DEFAULT_COUNTRY + end + # Sum CE hours from registrations whose certificate has been sent, optionally # narrowed by the given block. Whole totals drop the decimal. def issued_ce_hours diff --git a/app/frontend/javascript/controllers/ce_license_picker_controller.js b/app/frontend/javascript/controllers/ce_license_picker_controller.js index 40f56de15d..8c5c6b1b0e 100644 --- a/app/frontend/javascript/controllers/ce_license_picker_controller.js +++ b/app/frontend/javascript/controllers/ce_license_picker_controller.js @@ -1,20 +1,26 @@ import { Controller } from "@hotwired/stimulus" // CE license picker. When the admin chooses a license from the dropdown, fill the -// type / number / state / expiry fields from that license (carried on each option's -// data attributes) so the fields describe the selected license. Choosing "Create -// new license" clears them for a fresh entry. +// type / number / state / country / expiry fields from that license (carried on each +// option's data attributes) so the fields describe the selected license. Choosing +// "Create new license" clears them for a fresh entry. A state the US list can't show +// goes in the "Outside USA?" box, opened along with any country on file. export default class extends Controller { - static targets = ["select", "kind", "number", "state", "expires"] + static targets = ["select", "kind", "number", "state", "typedState", "country", "outsideUsa", "expires"] sync() { const option = this.selectTarget.selectedOptions[0] if (!option) return const isNew = this.selectTarget.value === "new" + const state = isNew ? "" : (option.dataset.state || "") + const listed = [...this.stateTarget.options].some((stateOption) => stateOption.value === state) this.kindTarget.value = isNew ? "" : (option.dataset.kind || "") this.numberTarget.value = isNew ? "" : (option.dataset.number || "") - this.stateTarget.value = isNew ? "" : (option.dataset.state || "") + this.stateTarget.value = listed ? state : "" + this.typedStateTarget.value = listed ? "" : state + this.countryTarget.value = isNew ? "" : (option.dataset.country || "") + this.outsideUsaTarget.open = option.dataset.outsideUsa === "true" this.expiresTarget.value = isNew ? "" : (option.dataset.expires || "") } } diff --git a/app/helpers/geography_helper.rb b/app/helpers/geography_helper.rb index e2319a9056..d5cc0382bc 100644 --- a/app/helpers/geography_helper.rb +++ b/app/helpers/geography_helper.rb @@ -55,13 +55,22 @@ def us_states ] end + def us_state?(abbreviation) + us_states.any? { |_name, abbr| abbr == abbreviation } + end + + # A saved state the US-state dropdown can't show, which belongs in the "Outside USA?" box. + def outside_usa_state?(state) + state.present? && !us_state?(state) + end + # State dropdown options that always include the address's current value, even # when it isn't a US state (e.g. an international province like "ON" or # "England"). Without this the US-only list can't match such a value, so the # select renders blank and saving wipes the state — tripping the presence # validation on an otherwise-untouched address. def state_select_options(current = nil) - return us_states if current.blank? || us_states.any? { |_name, abbr| abbr == current } + return us_states if current.blank? || us_state?(current) us_states + [ [ current, current ] ] end diff --git a/app/models/address.rb b/app/models/address.rb index f6bda0fce7..62d16d59f6 100644 --- a/app/models/address.rb +++ b/app/models/address.rb @@ -1,4 +1,6 @@ class Address < ApplicationRecord + include OutsideUsaTypeable + belongs_to :created_by, class_name: "User", optional: true belongs_to :updated_by, class_name: "User", optional: true @@ -31,6 +33,9 @@ class Address < ApplicationRecord ].freeze belongs_to :addressable, polymorphic: true, touch: true + + typed_outside_usa :state + # Affiliations that point to this address as their organization address. Nullify # the link rather than block deletion when an org address is removed. has_many :affiliations, foreign_key: :organization_address_id, dependent: :nullify, inverse_of: :organization_address diff --git a/app/models/concerns/outside_usa_typeable.rb b/app/models/concerns/outside_usa_typeable.rb new file mode 100644 index 0000000000..7e2456a183 --- /dev/null +++ b/app/models/concerns/outside_usa_typeable.rb @@ -0,0 +1,22 @@ +# Pairs a state attribute with the `_outside_usa` box from +# shared/_state_select. A typed value wins over the dropdown whichever one params +# assign first, and writes straight to the column so nested autosave sees the change. +module OutsideUsaTypeable + extend ActiveSupport::Concern + + class_methods do + def typed_outside_usa(attribute) + typed = :"#{attribute}#{OutsideUsaState::SUFFIX}" + attr_reader typed + + define_method(:"#{typed}=") do |value| + instance_variable_set(:"@#{typed}", value.to_s.strip.presence) + public_send(:"#{attribute}=", public_send(attribute)) + end + + define_method(:"#{attribute}=") do |value| + super(OutsideUsaState.pick(value, public_send(typed))) + end + end + end +end diff --git a/app/models/continuing_education_registration.rb b/app/models/continuing_education_registration.rb index 841e63bad8..04f100ecf9 100644 --- a/app/models/continuing_education_registration.rb +++ b/app/models/continuing_education_registration.rb @@ -25,7 +25,8 @@ def communications_email # them from params and routes them through #assign_license; declared here so the # form builder can bind to them (SimpleForm reads the object when a field's # value is nil, e.g. a blank expiry on a placeholder license). - attr_accessor :license_kind, :license_number, :license_issuing_state, :license_expires_on + attr_accessor :license_kind, :license_number, :license_issuing_state, :license_issuing_state_outside_usa, + :license_issuing_country, :license_expires_on # Set when the transfer flow creates the destination record with a deliberately # snapshotted hours/cost (including a $0 cost), so #default_from_event doesn't @@ -160,10 +161,11 @@ def required_attendance_minutes # wins, to avoid duplicating or colliding on the unique (person, number) index. # Does not save the registration itself — callers persist it alongside their other # changes. - def assign_license(number:, kind:, issuing_state: nil, expires_on: nil, license_id: nil) + def assign_license(number:, kind:, issuing_state: nil, issuing_country: nil, expires_on: nil, license_id: nil) number = number.to_s.strip.presence kind = kind.to_s.strip.presence issuing_state = issuing_state.to_s.strip.presence + issuing_country = issuing_country.presence expires_on = expires_on.presence current = professional_license person = event_registration.registrant @@ -171,7 +173,7 @@ def assign_license(number:, kind:, issuing_state: nil, expires_on: nil, license_ if license_id.present? && license_id != "new" picked = person.professional_licenses.find_by(id: license_id) if picked - picked.update!(number: number, kind: kind, issuing_state: issuing_state, expires_on: expires_on) + picked.update!(number: number, kind: kind, issuing_state: issuing_state, issuing_country: issuing_country, expires_on: expires_on) self.professional_license = picked return end @@ -184,7 +186,7 @@ def assign_license(number:, kind:, issuing_state: nil, expires_on: nil, license_ self.professional_license = match else target = license_id == "new" ? person.professional_licenses.new : current - target.update!(number: number, kind: kind, issuing_state: issuing_state, expires_on: expires_on) + target.update!(number: number, kind: kind, issuing_state: issuing_state, issuing_country: issuing_country, expires_on: expires_on) self.professional_license = target end end diff --git a/app/models/person.rb b/app/models/person.rb index 7447566ef4..3952d3f1e2 100644 --- a/app/models/person.rb +++ b/app/models/person.rb @@ -181,7 +181,7 @@ def known_email_2_type? = email_2_type.in?(CONTACT_TYPES) # A blank row (number + kind + state + expiry all empty) is ignored rather than # creating an empty license. accepts_nested_attributes_for :professional_licenses, allow_destroy: true, - reject_if: proc { |attrs| attrs.slice("number", "kind", "issuing_state", "expires_on").values.all?(&:blank?) } + reject_if: proc { |attrs| attrs.slice("number", "kind", "issuing_state", "issuing_state_outside_usa", "issuing_country", "expires_on").values.all?(&:blank?) } # Search Cop include SearchCop diff --git a/app/models/professional_license.rb b/app/models/professional_license.rb index 3d6bb5cd90..f2734d6ed7 100644 --- a/app/models/professional_license.rb +++ b/app/models/professional_license.rb @@ -1,6 +1,10 @@ class ProfessionalLicense < ApplicationRecord + include OutsideUsaTypeable + has_paper_trail + typed_outside_usa :issuing_state + belongs_to :person belongs_to :created_by, class_name: "User", optional: true belongs_to :updated_by, class_name: "User", optional: true diff --git a/app/models/profile_change_request.rb b/app/models/profile_change_request.rb index d6f9a60262..778b1e7a96 100644 --- a/app/models/profile_change_request.rb +++ b/app/models/profile_change_request.rb @@ -1,4 +1,6 @@ class ProfileChangeRequest < ApplicationRecord + include OutsideUsaTypeable + belongs_to :person belongs_to :requested_by, class_name: "User" belongs_to :reviewed_by, class_name: "User", optional: true @@ -9,6 +11,8 @@ class ProfileChangeRequest < ApplicationRecord belongs_to :professional_license, optional: true belongs_to :proposed_organization_address, class_name: "Address", optional: true + typed_outside_usa :proposed_state + OTHER_FIELD = "other".freeze LICENSE_FIELD = "professional_license".freeze EMAIL_FIELDS = %w[primary_email secondary_email].freeze diff --git a/app/policies/person_policy.rb b/app/policies/person_policy.rb index 9ca5e7c226..22615a24b3 100644 --- a/app/policies/person_policy.rb +++ b/app/policies/person_policy.rb @@ -163,6 +163,7 @@ def send_form_link? :street_address, :city, :state, + :state_outside_usa, :zip_code, :country, :county, @@ -221,7 +222,7 @@ def send_form_link? ], comments_attributes: [ :id, :topic, :body, :flagged, :_destroy ], notifications_attributes: Notification::PERMITTED_LOG_ATTRIBUTES, - professional_licenses_attributes: [ :id, :number, :kind, :issuing_state, :expires_on, :_destroy ] + professional_licenses_attributes: [ :id, :number, :kind, :issuing_state, :issuing_state_outside_usa, :issuing_country, :expires_on, :_destroy ] ) next permitted if admin? diff --git a/app/services/outside_usa_state.rb b/app/services/outside_usa_state.rb new file mode 100644 index 0000000000..0d8d437862 --- /dev/null +++ b/app/services/outside_usa_state.rb @@ -0,0 +1,25 @@ +# Resolves a state submitted through shared/_state_select: the "Outside USA?" box's +# typed value when filled, otherwise the US-state dropdown's pick. +module OutsideUsaState + SUFFIX = "_outside_usa" + + def self.pick(dropdown, typed) + typed.to_s.strip.presence || dropdown + end + + # A form's answers, keyed by field id (or field id → resource id for fanned-out + # fields), with each state typed under `form_fields_outside_usa` laid over its dropdown. + def self.form_field_answers(params, scope) + answers = params.dig(scope, :form_fields)&.to_unsafe_h || {} + typed = params.dig(scope, :"form_fields#{SUFFIX}")&.to_unsafe_h || {} + answers.deep_merge(filled(typed)) + end + + def self.filled(typed) + typed.each_with_object({}) do |(key, value), result| + value = value.is_a?(Hash) ? filled(value) : value.to_s.strip + result[key] = value if value.present? + end + end + private_class_method :filled +end diff --git a/app/views/addresses/_state_field.html.erb b/app/views/addresses/_state_field.html.erb index cb13c182d4..ff57a4c0a1 100644 --- a/app/views/addresses/_state_field.html.erb +++ b/app/views/addresses/_state_field.html.erb @@ -7,13 +7,24 @@ class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" } %> <% else %> - <%= f.input :state, - label: "State", - as: :select, - collection: state_select_options(address.default_state), - include_blank: true, - selected: address.default_state, - input_html: { - class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" - } %> +
+ <%= f.input :state, + label: "State", + as: :select, + collection: state_select_options(address.default_state), + include_blank: true, + selected: address.default_state, + wrapper_html: { class: "mb-0" }, + input_html: { + class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" + } %> + <%= render layout: "shared/outside_usa", locals: { open: false } do %> + <%= f.input :state_outside_usa, + label: "State / province", + wrapper_html: { class: "mb-0" }, + input_html: { + class: "rounded-md border-gray-300 shadow-sm focus:ring-blue-500 focus:border-blue-500" + } %> + <% end %> +
<% end %> diff --git a/app/views/continuing_education_registrations/_details_section.html.erb b/app/views/continuing_education_registrations/_details_section.html.erb index 573c9c4d21..5d51640c89 100644 --- a/app/views/continuing_education_registrations/_details_section.html.erb +++ b/app/views/continuing_education_registrations/_details_section.html.erb @@ -19,7 +19,7 @@ <%= f.select :professional_license_id, options_for_select( - licenses.map { |l| [ l.name, l.id, { "data-kind" => l.kind, "data-number" => l.number, "data-state" => l.issuing_state, "data-expires" => l.expires_on&.iso8601 } ] } + + licenses.map { |l| [ l.name, l.id, { "data-kind" => l.kind, "data-number" => l.number, "data-state" => l.issuing_state, "data-country" => l.issuing_country, "data-outside-usa" => l.decorate.outside_usa?, "data-expires" => l.expires_on&.iso8601 } ] } + [ [ "Create new license", "new" ] ], license&.id ), @@ -49,10 +49,21 @@ <%= f.select :license_issuing_state, us_states, - { selected: license&.issuing_state, include_blank: "Select a state" }, + { selected: (license&.issuing_state unless outside_usa_state?(license&.issuing_state)), include_blank: "Select a state" }, id: "continuing_education_registration_license_issuing_state", data: { "ce-license-picker-target": "state" }, class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + <%= render layout: "shared/outside_usa", locals: { open: license&.decorate&.outside_usa?, data: { "ce-license-picker-target": "outsideUsa" } } do %> + <%= f.text_field :license_issuing_state_outside_usa, id: "continuing_education_registration_license_issuing_state_outside_usa", + value: (license&.issuing_state if outside_usa_state?(license&.issuing_state)), + placeholder: "State / province", aria: { label: "State or province" }, data: { "ce-license-picker-target": "typedState" }, + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + <%= f.select :license_issuing_country, GeographyHelper::COUNTRIES, + { selected: license&.issuing_country, include_blank: "Select a country" }, + id: "continuing_education_registration_license_issuing_country", aria: { label: "Country" }, + data: { "ce-license-picker-target": "country" }, + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + <% end %>
diff --git a/app/views/continuing_education_registrations/show.html.erb b/app/views/continuing_education_registrations/show.html.erb index 34afe1b801..2f53578256 100644 --- a/app/views/continuing_education_registrations/show.html.erb +++ b/app/views/continuing_education_registrations/show.html.erb @@ -40,7 +40,7 @@
Issuing state
-
<%= license&.issuing_state.presence || "—" %>
+
<%= license&.decorate&.issuing_place.presence || "—" %>
License expires
diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 89593790c7..545aac63fd 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -158,7 +158,7 @@
Issuing state
-
<%= license_issuing_state.presence || "—" %>
+
<%= license&.decorate&.issuing_place.presence || "—" %>
@@ -204,7 +204,7 @@ + + <%= render layout: "shared/outside_usa", locals: { open: license&.decorate&.outside_usa? } do %> + <%= form.text_field :license_issuing_state_outside_usa, id: "license_issuing_state_outside_usa", + value: (license_issuing_state if outside_usa_state?(license_issuing_state)), + placeholder: "State / province", aria: { label: "State or province" }, + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + <%= form.select :license_issuing_country, GeographyHelper::COUNTRIES, + { selected: license&.issuing_country, include_blank: "Select a country" }, + id: "license_issuing_country", aria: { label: "Country" }, + class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm #{license&.issuing_country.present? ? "text-gray-900" : "text-gray-400"} shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> + <% end %>
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 9731a7ae31..c9db3c9bae 100644 --- a/app/views/events/public_registrations/_form_field_input.html.erb +++ b/app/views/events/public_registrations/_form_field_input.html.erb @@ -40,15 +40,25 @@ <% case field.answer_type %> <% when "free_form_input_one_line" %> <% if field.field_identifier&.end_with?("_state") %> + <%# Not `required`: a registrant abroad leaves it blank and types in the box below. %> + <% state = OutsideUsaState.pick(value, params.dig(param_scope, "form_fields#{OutsideUsaState::SUFFIX}", field.id.to_s, *resource&.id&.to_s)) %> + <%= render layout: "shared/outside_usa", locals: { open: outside_usa_state?(state) } do %> + " + id="<%= field_id %><%= OutsideUsaState::SUFFIX %>" + value="<%= state if outside_usa_state?(state) %>" + placeholder="State / province" + aria-label="State or province" + class="<%= input_classes %>"> + <% end %> <% elsif field.field_identifier&.end_with?("_country") %> <% selected_country = value.to_s.strip %> - <%= render layout: "shared/outside_usa", locals: { open: outside_usa_state?(state) } do %> + <% country_field = field.paired_country_field unless resource %> + <% if country_field %> + <% country = params.dig(param_scope, "form_fields", country_field.id.to_s).presence || + @prefill_values&.[](country_field.id) || + submission&.form_answers&.find_by(form_field: country_field)&.submitted_answer %> + <% country_error = @field_errors&.dig(country_field.id) %> + <% end %> + <%= render layout: "shared/outside_usa", + locals: { open: outside_usa_state?(state) || (country.present? && country != Address::DEFAULT_COUNTRY) || country_error.present? } do %> " id="<%= field_id %><%= OutsideUsaState::SUFFIX %>" @@ -58,6 +68,23 @@ placeholder="Province / State" aria-label="Province or state" class="<%= input_classes %>"> + <% if country_field %> + <%# Not `required` either: a closed box can't be focused, so the browser would block the submit silently. %> + + <% if country_error %> +

+ <%= strip_tags(country_field.name) %> <%= country_error %> +

+ <% end %> + <% end %> <% end %> <% elsif field.field_identifier&.end_with?("_country") %> <% selected_country = value.to_s.strip %> diff --git a/app/views/forms/smart_form_settings.html.erb b/app/views/forms/smart_form_settings.html.erb index 5b4057ebe0..e4da3c6225 100644 --- a/app/views/forms/smart_form_settings.html.erb +++ b/app/views/forms/smart_form_settings.html.erb @@ -72,6 +72,25 @@ <% end %> +
+
+

Endings that change how a question is asked

+
+
+

Any smart field with one of these endings, listed above or not.

+
+ <% SmartFormFields::ENDINGS.each do |ending, effect| %> +
+
+ <%= ending %> +
+
<%= effect %>
+
+ <% end %> +
+
+
+

Smart fields that do nothing extra

@@ -103,7 +122,7 @@

- A smart field not listed here does nothing — the answer is stored on the submission and no record is updated. + A smart field not listed here, and without one of the endings above, does nothing — the answer is stored on the submission and no record is updated. Changing a smart field on a live form changes where future answers go; answers already submitted stay as they are.

diff --git a/db/seeds/dev/annual_evaluation.rb b/db/seeds/dev/annual_evaluation.rb index faa800b787..b174ba9660 100644 --- a/db/seeds/dev/annual_evaluation.rb +++ b/db/seeds/dev/annual_evaluation.rb @@ -104,6 +104,7 @@ def ae_slider!(form, name, **opts) field_identifier: "ce_license_kind", hint_text: "e.g. LMFT, LCSW, LPCC, MA Ed.") ae_field!(form, "License number", :free_form_input_one_line, width: :quarter, field_identifier: "ce_license_number") ae_field!(form, "State license was issued", :free_form_input_one_line, width: :quarter, field_identifier: "ce_license_issuing_state") +ae_field!(form, "Country license was issued", :free_form_input_one_line, width: :quarter, field_identifier: "ce_license_issuing_country") ae_field!(form, "License expiration date", :free_form_input_one_line, width: :quarter, field_identifier: "ce_license_expires_on", input_type: :date) # ── Your work ──────────────────────────────────────────────────────────────── diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 6b009761de..66acf73cb6 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -489,7 +489,7 @@ # A short post-event survey delivered on the flagship's "Feedback surveys" callout, # open now (no drip date) so a callout form can be filled in from any flagship -# ticket. Its state question exercises the "Outside USA?" box. The built-in callout +# ticket. Its state question exercises the "Outside USA?" box, with the country inside it. The built-in callout # links its survey forms only when created, so link this one here. Idempotent. post_event_survey = Form.survey("post_event_survey") unless post_event_survey @@ -497,6 +497,7 @@ [ { name: "How likely are you to recommend this training to a colleague?", answer_type: :slider, required: true }, { name: "Which state or province do you mostly work in?", answer_type: :free_form_input_one_line, field_identifier: "practice_state" }, + { name: "Which country do you mostly work in?", answer_type: :free_form_input_one_line, field_identifier: "practice_country" }, { name: "What will you take back to your practice?", answer_type: :free_form_input_paragraph } ].each { |question| post_event_survey.form_fields.create!(status: :active, **question) } end diff --git a/spec/models/form_field_spec.rb b/spec/models/form_field_spec.rb index 543d76f23c..53c1a3361e 100644 --- a/spec/models/form_field_spec.rb +++ b/spec/models/form_field_spec.rb @@ -179,6 +179,33 @@ it { should define_enum_for(:width).with_values([ :full, :half, :third, :quarter ]).with_prefix(true) } end + describe "state and country pairing" do + let(:form) { create(:form) } + let!(:state) { create(:form_field, form: form, field_identifier: "practice_state") } + let!(:country) { create(:form_field, form: form, field_identifier: "practice_country") } + + it "pairs a _country question with the same form's matching _state question" do + expect(state.paired_country_field).to eq(country) + expect(country).to be_paired_with_state + end + + it "leaves a _country question unpaired without a matching _state question" do + lone = create(:form_field, form: form, field_identifier: "organization_country") + + expect(lone).not_to be_paired_with_state + end + + it "ignores an inactive country question" do + country.update!(status: :inactive) + + expect(state.paired_country_field).to be_nil + end + + it "takes no grid space for a paired country question" do + expect(country.grid_span_class).to eq("hidden") + end + end + describe "#grid_span_class" do let(:form) { create(:form) } diff --git a/spec/requests/outside_usa_state_spec.rb b/spec/requests/outside_usa_state_spec.rb index be924db686..0cff2f1b2a 100644 --- a/spec/requests/outside_usa_state_spec.rb +++ b/spec/requests/outside_usa_state_spec.rb @@ -120,6 +120,28 @@ def page_html expect(FormAnswer.find_by(form_field: state_field).submitted_answer).to eq("Ontario") end + context "with a matching country question" do + let!(:country_field) { create(:form_field, form: form, name: "Country", field_identifier: "mailing_country") } + + it "asks the country inside the state's Outside USA? box only" do + get public_form_path(form.slug) + + countries = page_html.css("select[name='public_registration[form_fields][#{country_field.id}]']") + expect(countries.size).to eq(1) + expect(countries.first.ancestors("details")).to be_present + end + + it "records the country picked in the box" do + post public_form_path(form.slug), params: { public_registration: { + Honeypot::FIELD_NAME => "", + form_fields: { state_field.id.to_s => "", country_field.id.to_s => "Canada" }, + form_fields_outside_usa: { state_field.id.to_s => "Ontario" } + } } + + expect(FormAnswer.find_by(form_field: country_field).submitted_answer).to eq("Canada") + end + end + it "keeps a typed state in its box when the form comes back with errors" do create(:form_field, form: form, name: "Pet", field_identifier: "pet_name", required: true) diff --git a/spec/services/event_registration_services/public_registration_spec.rb b/spec/services/event_registration_services/public_registration_spec.rb index 36342a6502..96fc638294 100644 --- a/spec/services/event_registration_services/public_registration_spec.rb +++ b/spec/services/event_registration_services/public_registration_spec.rb @@ -964,13 +964,14 @@ def ce_field_id(key) ce_form.form_fields.find_by!(field_identifier: key).id.to_s end - def register_with_ce(answer, license: nil, kind: nil, issuing_state: nil, expires_on: nil) + def register_with_ce(answer, license: nil, kind: nil, issuing_state: nil, issuing_country: nil, expires_on: nil) params = base_form_params(first_name: "Cy", last_name: "Reed", email: "cy@example.com") ce_params = {} ce_params[ce_field_id(described_class::CE_CREDIT_INTEREST_IDENTIFIER)] = answer unless answer.nil? ce_params[ce_field_id(described_class::CE_LICENSE_NUMBER_IDENTIFIER)] = license if license ce_params[ce_field_id(described_class::CE_LICENSE_KIND_IDENTIFIER)] = kind if kind ce_params[ce_field_id(described_class::CE_LICENSE_ISSUING_STATE_IDENTIFIER)] = issuing_state if issuing_state + ce_params[ce_field_id(described_class::CE_LICENSE_ISSUING_COUNTRY_IDENTIFIER)] = issuing_country if issuing_country ce_params[ce_field_id(described_class::CE_LICENSE_EXPIRES_ON_IDENTIFIER)] = expires_on if expires_on described_class.call(event: event, registration_form: form, form_params: params, continuing_education_form: ce_form, continuing_education_params: ce_params) @@ -1039,6 +1040,12 @@ def register_with_ce(answer, license: nil, kind: nil, issuing_state: nil, expire expect(license.expires_on).to eq(Date.new(2027, 5, 31)) end + it "records the license issuing country when given" do + result = register_with_ce("Yes", license: "555", issuing_state: "Ontario", issuing_country: "Canada") + license = result.event_registration.continuing_education_registrations.first.professional_license + expect(license).to have_attributes(issuing_state: "Ontario", issuing_country: "Canada") + end + it "uses a placeholder license when no number is given" do result = register_with_ce("Yes") expect(result.event_registration.continuing_education_registrations.first.professional_license.number).to be_nil diff --git a/spec/services/outside_usa_state_spec.rb b/spec/services/outside_usa_state_spec.rb index 78f41e66ee..96b6834f00 100644 --- a/spec/services/outside_usa_state_spec.rb +++ b/spec/services/outside_usa_state_spec.rb @@ -34,6 +34,30 @@ def params_for(form_fields:, typed: nil) expect(described_class.form_field_answers(params, :public_registration)).to eq("1" => { "7" => "Ontario", "8" => "NY" }) end + context "with a country question paired to the state" do + let(:form) { create(:form) } + let(:state) { create(:form_field, form: form, field_identifier: "mailing_state") } + let(:country) { create(:form_field, form: form, field_identifier: "mailing_country") } + + it "records the US for a blank country beside a US state" do + params = params_for(form_fields: { state.id.to_s => "CA", country.id.to_s => "" }) + + expect(described_class.form_field_answers(params, :public_registration)[country.id.to_s]).to eq("United States") + end + + it "leaves a blank country alone beside a typed state" do + params = params_for(form_fields: { state.id.to_s => "", country.id.to_s => "" }, typed: { state.id.to_s => "Ontario" }) + + expect(described_class.form_field_answers(params, :public_registration)[country.id.to_s]).to eq("") + end + + it "keeps a picked country" do + params = params_for(form_fields: { state.id.to_s => "CA", country.id.to_s => "Mexico" }) + + expect(described_class.form_field_answers(params, :public_registration)[country.id.to_s]).to eq("Mexico") + end + end + it "returns the answers untouched when no state was typed" do params = params_for(form_fields: { "1" => "CA" }) diff --git a/spec/system/public_registration_form_submission_spec.rb b/spec/system/public_registration_form_submission_spec.rb index b8a701e9c9..69a2a2c477 100644 --- a/spec/system/public_registration_form_submission_spec.rb +++ b/spec/system/public_registration_form_submission_spec.rb @@ -395,7 +395,6 @@ def fill_full_registration fill_pr_text reg_field("mailing_city"), with: "Los Angeles" select_pr reg_field("mailing_state"), "California (CA)" fill_pr_text reg_field("mailing_zip"), with: "90001" - select_pr reg_field("mailing_country"), "United States" fill_pr_text reg_field("phone"), with: "555-123-4567" choose_pr_radio reg_field("phone_type"), "Work" @@ -407,7 +406,6 @@ def fill_full_registration fill_pr_text reg_field("organization_city"), with: "Pasadena" select_pr reg_field("organization_state"), "California (CA)" fill_pr_text reg_field("organization_zip"), with: "91101" - select_pr reg_field("organization_country"), "United States" select_pr reg_field("primary_sector"), "Education" check_pr_box reg_field("additional_sectors"), sector_mental_health.id.to_s From 00fd0cd5206fb05b1fd1c588bc3d2df4db3d794c Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 09:24:36 -0400 Subject: [PATCH 06/13] Save a US license's country, and put the location questions on any dev survey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A license picked from the US-state dropdown named no country, unlike a form answer beside a US state, which records the US — so licenses now default the same way. Dev databases that already held a post-event survey never got its location questions or an open callout to try them on; the seed now adds both. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/professional_license.rb | 6 +++++ db/seeds/dev/events_management.rb | 25 +++++++++++++------ .../2026-10-25-outside-usa-license-states.yml | 3 +++ spec/models/professional_license_spec.rb | 14 +++++++++++ 4 files changed, 40 insertions(+), 8 deletions(-) diff --git a/app/models/professional_license.rb b/app/models/professional_license.rb index f2734d6ed7..bfc7620bd2 100644 --- a/app/models/professional_license.rb +++ b/app/models/professional_license.rb @@ -17,6 +17,7 @@ class ProfessionalLicense < ApplicationRecord # whether those registrations carry payments. prepend so this runs before the # dependent: :destroy cascade below clears those registrations. before_destroy :prevent_destroy_with_ce, prepend: true + before_validation :default_issuing_country # A license is identified by its kind + number, so the same number under two # different kinds is allowed; only a duplicate (kind, number) pair is rejected. @@ -78,6 +79,11 @@ def used_for_ce? private + # A US state with no country named is a US license. + def default_issuing_country + self.issuing_country = Address::DEFAULT_COUNTRY if issuing_country.blank? && issuing_state.in?(Address::US_STATE_ABBREVIATIONS) + end + def prevent_destroy_with_ce return if removable? diff --git a/db/seeds/dev/events_management.rb b/db/seeds/dev/events_management.rb index 66acf73cb6..079230b63d 100644 --- a/db/seeds/dev/events_management.rb +++ b/db/seeds/dev/events_management.rb @@ -487,24 +487,33 @@ HTML end -# A short post-event survey delivered on the flagship's "Feedback surveys" callout, -# open now (no drip date) so a callout form can be filled in from any flagship -# ticket. Its state question exercises the "Outside USA?" box, with the country inside it. The built-in callout -# links its survey forms only when created, so link this one here. Idempotent. +# A post-event survey delivered on the flagship's "Feedback surveys" callout, open +# now so a callout form can be filled in from any flagship ticket. Its state question +# exercises the "Outside USA?" box, with the country inside it. The built-in callout +# links its survey forms only when created, so link this one here; an existing survey +# gets the two location questions and loses its drip date. Idempotent. post_event_survey = Form.survey("post_event_survey") unless post_event_survey post_event_survey = Form.create!(name: "Post-event survey", role: "post_event_survey") [ { name: "How likely are you to recommend this training to a colleague?", answer_type: :slider, required: true }, - { name: "Which state or province do you mostly work in?", answer_type: :free_form_input_one_line, field_identifier: "practice_state" }, - { name: "Which country do you mostly work in?", answer_type: :free_form_input_one_line, field_identifier: "practice_country" }, { name: "What will you take back to your practice?", answer_type: :free_form_input_paragraph } ].each { |question| post_event_survey.form_fields.create!(status: :active, **question) } end +[ + { name: "Which state or province do you mostly work in?", field_identifier: "practice_state" }, + { name: "Which country do you mostly work in?", field_identifier: "practice_country" } +].each do |question| + next if post_event_survey.form_fields.exists?(field_identifier: question[:field_identifier]) + + post_event_survey.form_fields.create!(status: :active, answer_type: :free_form_input_one_line, + position: post_event_survey.form_fields.maximum(:position).to_i + 1, **question) +end + feedback_surveys = flagship&.registration_ticket_callouts&.find_by(builtin_key: "feedback_surveys") -if feedback_surveys && !feedback_surveys.forms.include?(post_event_survey) - feedback_surveys.registration_ticket_callout_forms.create!(form: post_event_survey) +if feedback_surveys + feedback_surveys.registration_ticket_callout_forms.find_or_initialize_by(form: post_event_survey).update!(display_from: nil) feedback_surveys.update!(hidden: false) end diff --git a/db/seeds/features/2026-10-25-outside-usa-license-states.yml b/db/seeds/features/2026-10-25-outside-usa-license-states.yml index 7f70004c9e..1654b24586 100644 --- a/db/seeds/features/2026-10-25-outside-usa-license-states.yml +++ b/db/seeds/features/2026-10-25-outside-usa-license-states.yml @@ -10,3 +10,6 @@ pro_tips: - "Covers licenses on the person form, the CE page on the registration ticket, the admin license and CE registration pages, address and work-address forms, and state questions on public and registration forms." - "A typed state wins over the dropdown. Clear the box to go back to a US state." - "A license's state and country show together, e.g. \"Ontario, Canada\"." + - "A form question whose smart field ends in _country is asked inside the matching _state question's Outside USA? box — see Smart field settings." + - "Left blank beside a US state, a country — on a license or a form answer — is saved as United States." + - "The registration form's CE section asks which country issued the license." diff --git a/spec/models/professional_license_spec.rb b/spec/models/professional_license_spec.rb index 74622afc8b..f775efca82 100644 --- a/spec/models/professional_license_spec.rb +++ b/spec/models/professional_license_spec.rb @@ -109,6 +109,20 @@ def attach_ce(paid: false) end end + describe "issuing country" do + it "is the US for a US state with no country named" do + expect(create(:professional_license, issuing_state: "CA", issuing_country: "").issuing_country).to eq("United States") + end + + it "keeps a country that was named" do + expect(create(:professional_license, issuing_state: "CA", issuing_country: "Mexico").issuing_country).to eq("Mexico") + end + + it "stays blank for a state outside the USA" do + expect(create(:professional_license, issuing_state: "Ontario", issuing_country: nil).issuing_country).to be_nil + end + end + describe "#issuing_state_outside_usa=" do it "wins over the dropdown whichever is assigned first" do typed_first = described_class.new(issuing_state_outside_usa: "Ontario", issuing_state: "CA") From 8bcd8b27c62ed12a61df4baf5102628cc54bea8c Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 10:18:44 -0400 Subject: [PATCH 07/13] Keep a paired country question's answer visible on the submission views MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grid_span_class is read by the answer-display pages as well as the form pages, so hiding the paired country field there dropped its recorded answer from the registration confirmation page — including the United States now filled in automatically. Skip the field where the form is asked instead. Co-Authored-By: Claude Opus 5 (1M context) --- app/models/concerns/outside_usa_typeable.rb | 2 +- app/models/form_field.rb | 2 -- app/services/outside_usa_state.rb | 2 +- .../events/public_registrations/new.html.erb | 6 +++++ app/views/forms/show.html.erb | 2 ++ app/views/public_forms/show.html.erb | 2 ++ spec/models/form_field_spec.rb | 4 ---- spec/requests/outside_usa_state_spec.rb | 22 +++++++++++++++++++ 8 files changed, 34 insertions(+), 8 deletions(-) diff --git a/app/models/concerns/outside_usa_typeable.rb b/app/models/concerns/outside_usa_typeable.rb index 7e2456a183..b5e40fa26d 100644 --- a/app/models/concerns/outside_usa_typeable.rb +++ b/app/models/concerns/outside_usa_typeable.rb @@ -1,5 +1,5 @@ # Pairs a state attribute with the `_outside_usa` box from -# shared/_state_select. A typed value wins over the dropdown whichever one params +# shared/_outside_usa. A typed value wins over the dropdown whichever one params # assign first, and writes straight to the column so nested autosave sees the change. module OutsideUsaTypeable extend ActiveSupport::Concern diff --git a/app/models/form_field.rb b/app/models/form_field.rb index fbed68ea62..a835697df8 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -279,8 +279,6 @@ def html_id end def grid_span_class - return "hidden" if paired_with_state? - WIDTH_GRID_SPANS.fetch(width, "md:col-span-12") end diff --git a/app/services/outside_usa_state.rb b/app/services/outside_usa_state.rb index d970f11456..cc038bbb6a 100644 --- a/app/services/outside_usa_state.rb +++ b/app/services/outside_usa_state.rb @@ -1,4 +1,4 @@ -# Resolves a state submitted through shared/_state_select: the "Outside USA?" box's +# Resolves a state submitted through shared/_outside_usa: the "Outside USA?" box's # typed value when filled, otherwise the US-state dropdown's pick. module OutsideUsaState SUFFIX = "_outside_usa" diff --git a/app/views/events/public_registrations/new.html.erb b/app/views/events/public_registrations/new.html.erb index 591ad2c3ca..f05397bf21 100644 --- a/app/views/events/public_registrations/new.html.erb +++ b/app/views/events/public_registrations/new.html.erb @@ -120,6 +120,8 @@
<% @form_fields.each do |field| %> + <%# Asked inside its _state question's "Outside USA?" box. %> + <% next if field.paired_with_state? %> <% if field.group_header? %> <%= render "events/public_registrations/group_header", field: field %> <% else %> @@ -142,6 +144,8 @@ <%= render "forms/header", form: @scholarship_form, event: @event %>
<% @scholarship_form.form_fields.reorder(position: :asc).each do |field| %> + <%# Asked inside its _state question's "Outside USA?" box. %> + <% next if field.paired_with_state? %> <% if field.group_header? %> <%# Skip a section header that just repeats the form name shown as the title above %> <% next if field.name.to_s.strip.casecmp?(@scholarship_form.display_name.to_s.strip) %> @@ -181,6 +185,8 @@ <% end %>
<% @continuing_education_form.form_fields.reorder(position: :asc).each do |field| %> + <%# Asked inside its _state question's "Outside USA?" box. %> + <% next if field.paired_with_state? %> <% if field.group_header? %> <% next if field.name.to_s.strip.casecmp?(@continuing_education_form.display_name.to_s.strip) %> <%= render "events/public_registrations/group_header", field: field %> diff --git a/app/views/forms/show.html.erb b/app/views/forms/show.html.erb index 929b0b06f3..1f4ad3ac00 100644 --- a/app/views/forms/show.html.erb +++ b/app/views/forms/show.html.erb @@ -77,6 +77,8 @@
<% @form_fields.each do |field| %> + <%# Asked inside its _state question's "Outside USA?" box. %> + <% next if field.paired_with_state? %> <% conditional = field.conditional_visibility? %> <% if field.group_header? %>
diff --git a/app/views/public_forms/show.html.erb b/app/views/public_forms/show.html.erb index 11c0da7976..db9142a78e 100644 --- a/app/views/public_forms/show.html.erb +++ b/app/views/public_forms/show.html.erb @@ -40,6 +40,8 @@
<% @form_fields.each do |field| %> + <%# Asked inside its _state question's "Outside USA?" box. %> + <% next if field.paired_with_state? %> <% if field.group_header? %> <%= render "events/public_registrations/group_header", field: field %> <% else %> diff --git a/spec/models/form_field_spec.rb b/spec/models/form_field_spec.rb index 53c1a3361e..bd09fbd7c0 100644 --- a/spec/models/form_field_spec.rb +++ b/spec/models/form_field_spec.rb @@ -200,10 +200,6 @@ expect(state.paired_country_field).to be_nil end - - it "takes no grid space for a paired country question" do - expect(country.grid_span_class).to eq("hidden") - end end describe "#grid_span_class" do diff --git a/spec/requests/outside_usa_state_spec.rb b/spec/requests/outside_usa_state_spec.rb index 0cff2f1b2a..3e6a8d7cb4 100644 --- a/spec/requests/outside_usa_state_spec.rb +++ b/spec/requests/outside_usa_state_spec.rb @@ -155,4 +155,26 @@ def page_html expect(page_html.at_css("input[name='public_registration[form_fields_outside_usa][#{state_field.id}]']")["value"]).to eq("Ontario") end end + + describe "a submitted registration's answers" do + let(:event) { create(:event) } + let(:person) { create(:person) } + let!(:registration) { create(:event_registration, event: event, registrant: person) } + let(:form) { create(:form) } + let!(:state_field) { create(:form_field, form: form, name: "State", field_identifier: "mailing_state") } + let!(:country_field) { create(:form_field, form: form, name: "Country", field_identifier: "mailing_country") } + + it "shows the country answered in the box on the confirmation page" do + EventForm.create!(event: event, form: form, role: "registration") + submission = form.form_submissions.create!(person: person, event: event) + submission.form_answers.create!(form_field: state_field, submitted_answer: "Ontario") + submission.form_answers.create!(form_field: country_field, submitted_answer: "Canada") + + get event_public_registration_path(event, reg: registration.slug) + + row = page_html.css("dt").find { |label| label.text.strip == "Country" }&.parent + expect(row["class"]).not_to include("hidden") + expect(row.at_css("dd").text).to include("Canada") + end + end end From 925214d3241a7626059f4f8f03ca11e9d16d4262 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 11:24:36 -0400 Subject: [PATCH 08/13] Pair a country question only with a state question being asked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paired country sits inside its state question's "Outside USA?" box, so the page skips it. But the pairing was decided against every active field on the form, not against the fields the page renders — so when a visibility rule dropped the state question, the country question was skipped as well and vanished from the form. The default registration form hits this: person_contact_info is logged_out_only and KnownFieldIdentifiers lists mailing_state but has no mailing_country, so a signed-in registrant with a state on file was never asked their country and the answer was never recorded. A required paired country would have been worse — FormAnswerValidator still validates it, giving a blank-field error with no input on the page. Both pairing methods now take the questions being asked, which the page loops and the submitted answers already know. Co-Authored-By: Claude Opus 5 (1M context) --- app/models/form_field.rb | 29 ++++++++++++------- app/services/outside_usa_state.rb | 10 ++++--- .../events/callouts/_callout_form.html.erb | 7 +++-- .../_form_field_input.html.erb | 9 +++--- .../events/public_registrations/new.html.erb | 18 +++++++----- app/views/forms/show.html.erb | 6 ++-- app/views/public_forms/show.html.erb | 4 +-- .../2026-10-25-outside-usa-license-states.yml | 2 +- spec/models/form_field_spec.rb | 16 +++++----- spec/requests/outside_usa_state_spec.rb | 14 +++++++++ ...ublic_registration_form_submission_spec.rb | 6 ++++ 11 files changed, 79 insertions(+), 42 deletions(-) diff --git a/app/models/form_field.rb b/app/models/form_field.rb index a835697df8..305b5f4f07 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -282,18 +282,16 @@ def grid_span_class WIDTH_GRID_SPANS.fetch(width, "md:col-span-12") end - # A `_country` question on the same form as `_state` is asked - # inside the state's "Outside USA?" box rather than on its own. - def paired_country_field - return unless field_identifier&.end_with?(STATE_SUFFIX) - - form.form_fields.active.find_by(field_identifier: field_identifier.delete_suffix(STATE_SUFFIX) + COUNTRY_SUFFIX) + # A `_country` question is asked inside its `_state` question's + # "Outside USA?" box rather than on its own. Both read `among` — the questions + # being asked here — so a country whose state partner this page isn't asking + # (hidden as already answered, or as logged-out-only) is still asked on its own. + def paired_country_field(among:) + paired_field(STATE_SUFFIX, COUNTRY_SUFFIX, among) end - def paired_with_state? - return false unless field_identifier&.end_with?(COUNTRY_SUFFIX) - - form.form_fields.active.exists?(field_identifier: field_identifier.delete_suffix(COUNTRY_SUFFIX) + STATE_SUFFIX) + def paired_with_state?(among:) + paired_field(COUNTRY_SUFFIX, STATE_SUFFIX, among).present? end def answer_type_label @@ -610,4 +608,15 @@ def selected(report, value) false end end + + private + + # The question in `among` whose identifier is this one's with `own_suffix` + # swapped for `partner_suffix` (mailing_state <-> mailing_country). + def paired_field(own_suffix, partner_suffix, among) + return unless field_identifier&.end_with?(own_suffix) + + partner = field_identifier.delete_suffix(own_suffix) + partner_suffix + among.find { |field| field.field_identifier == partner } + end end diff --git a/app/services/outside_usa_state.rb b/app/services/outside_usa_state.rb index cc038bbb6a..c2b7580a8e 100644 --- a/app/services/outside_usa_state.rb +++ b/app/services/outside_usa_state.rb @@ -17,13 +17,15 @@ def self.form_field_answers(params, scope) end # A paired country question sits in the state's closed "Outside USA?" box, so - # someone who picked a US state leaves it blank; record the US for them. + # someone who picked a US state leaves it blank; record the US for them. Pairs + # within the submitted questions, which are the ones the form asked. def self.with_us_countries(answers) - FormField.where(id: answers.keys).where("field_identifier LIKE ?", "%\\#{FormField::STATE_SUFFIX}").find_each do |state_field| + asked = FormField.where(id: answers.keys).to_a + asked.each do |state_field| next unless answers[state_field.id.to_s].in?(Address::US_STATE_ABBREVIATIONS) - country_key = state_field.paired_country_field&.id&.to_s - answers[country_key] = Address::DEFAULT_COUNTRY if country_key && answers.key?(country_key) && answers[country_key].blank? + country_key = state_field.paired_country_field(among: asked)&.id&.to_s + answers[country_key] = Address::DEFAULT_COUNTRY if country_key && answers[country_key].blank? end answers end diff --git a/app/views/events/callouts/_callout_form.html.erb b/app/views/events/callouts/_callout_form.html.erb index d04c50eb26..fc52dc55a1 100644 --- a/app/views/events/callouts/_callout_form.html.erb +++ b/app/views/events/callouts/_callout_form.html.erb @@ -80,11 +80,14 @@ <% end %> <%= form_with url: registration_callout_form_submit_path(@event_registration.slug, callout, form), method: :post do %>
- <% form.form_fields.order(:position).each do |field| %> + <% asked = form.form_fields.order(:position).to_a %> + <% asked.each do |field| %> + <%# Asked inside its _state question's "Outside USA?" box. %> + <% next if field.paired_with_state?(among: asked) %> <% if field.group_header? %>

<%= field.name %>

<% else %> - <%= render "events/public_registrations/form_field", field: field, param_scope: "callout_form", required: field.answer_required?, submission: submission, + <%= render "events/public_registrations/form_field", field: field, asked: asked, param_scope: "callout_form", required: field.answer_required?, submission: submission, value: field.per_resource? ? submission&.per_resource_values(field) : submission&.form_answers&.find_by(form_field: field)&.submitted_answer %> <% end %> <% end %> 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 a0fd16883d..2ff077c8f9 100644 --- a/app/views/events/public_registrations/_form_field_input.html.erb +++ b/app/views/events/public_registrations/_form_field_input.html.erb @@ -1,9 +1,10 @@ -<%# locals: (field:, value: nil, label: nil, show_option_source: false, show_dropdown_warning: false, param_scope: "public_registration", required: nil, resource: nil, submission: nil) %> -<%# One input for a field — or, given a resource, one fanned-out copy of it (see _form_field). %> +<%# locals: (field:, asked: [], value: nil, label: nil, show_option_source: false, show_dropdown_warning: false, param_scope: "public_registration", required: nil, resource: nil, submission: nil) %> +<%# One input for a field — or, given a resource, one fanned-out copy of it (see _form_field). + `asked` is every question on the page, which decides state/country pairing. %> <% param_scope = local_assigns.fetch(:param_scope, "public_registration") %> <% return if field.group_header? %> <%# Asked inside its _state question's "Outside USA?" box instead. %> -<% return if field.paired_with_state? %> +<% return if field.paired_with_state?(among: asked) %> <%# Callers can force a field required (e.g. identity on a registration form); defaults to the field's own setting. %> <% required = local_assigns.fetch(:required, field.required) %> @@ -52,7 +53,7 @@ <% end %> - <% country_field = field.paired_country_field unless resource %> + <% country_field = field.paired_country_field(among: asked) unless resource %> <% if country_field %> <% country = params.dig(param_scope, "form_fields", country_field.id.to_s).presence || @prefill_values&.[](country_field.id) || diff --git a/app/views/events/public_registrations/new.html.erb b/app/views/events/public_registrations/new.html.erb index f05397bf21..51903a7e5e 100644 --- a/app/views/events/public_registrations/new.html.erb +++ b/app/views/events/public_registrations/new.html.erb @@ -121,7 +121,7 @@
<% @form_fields.each do |field| %> <%# Asked inside its _state question's "Outside USA?" box. %> - <% next if field.paired_with_state? %> + <% next if field.paired_with_state?(among: @form_fields) %> <% if field.group_header? %> <%= render "events/public_registrations/group_header", field: field %> <% else %> @@ -130,7 +130,7 @@ <% submitted_value = FormBuilderService::PAYMENT_METHOD_PAY_NOW %> <% end %>
- <%= render "events/public_registrations/form_field", field: field, value: submitted_value, label: email_label_overrides[field.field_identifier] %> + <%= render "events/public_registrations/form_field", field: field, asked: @form_fields, value: submitted_value, label: email_label_overrides[field.field_identifier] %>
<% end %> <% end %> @@ -143,9 +143,10 @@ <%= render "forms/header", form: @scholarship_form, event: @event %>
- <% @scholarship_form.form_fields.reorder(position: :asc).each do |field| %> + <% scholarship_fields = @scholarship_form.form_fields.reorder(position: :asc).to_a %> + <% scholarship_fields.each do |field| %> <%# Asked inside its _state question's "Outside USA?" box. %> - <% next if field.paired_with_state? %> + <% next if field.paired_with_state?(among: scholarship_fields) %> <% if field.group_header? %> <%# Skip a section header that just repeats the form name shown as the title above %> <% next if field.name.to_s.strip.casecmp?(@scholarship_form.display_name.to_s.strip) %> @@ -153,7 +154,7 @@ <% else %> <% submitted_value = params.dig(:public_registration, :form_fields, field.id.to_s) %>
- <%= render "events/public_registrations/form_field", field: field, value: submitted_value %> + <%= render "events/public_registrations/form_field", field: field, asked: scholarship_fields, value: submitted_value %>
<% end %> <% end %> @@ -184,16 +185,17 @@ <% end %>
- <% @continuing_education_form.form_fields.reorder(position: :asc).each do |field| %> + <% ce_fields = @continuing_education_form.form_fields.reorder(position: :asc).to_a %> + <% ce_fields.each do |field| %> <%# Asked inside its _state question's "Outside USA?" box. %> - <% next if field.paired_with_state? %> + <% next if field.paired_with_state?(among: ce_fields) %> <% if field.group_header? %> <% next if field.name.to_s.strip.casecmp?(@continuing_education_form.display_name.to_s.strip) %> <%= render "events/public_registrations/group_header", field: field %> <% else %> <% submitted_value = params.dig(:public_registration, :form_fields, field.id.to_s) %>
- <%= render "events/public_registrations/form_field", field: field, value: submitted_value %> + <%= render "events/public_registrations/form_field", field: field, asked: ce_fields, value: submitted_value %>
<% end %> <% end %> diff --git a/app/views/forms/show.html.erb b/app/views/forms/show.html.erb index 1f4ad3ac00..7df91a8497 100644 --- a/app/views/forms/show.html.erb +++ b/app/views/forms/show.html.erb @@ -78,7 +78,7 @@
<% @form_fields.each do |field| %> <%# Asked inside its _state question's "Outside USA?" box. %> - <% next if field.paired_with_state? %> + <% next if field.paired_with_state?(among: @form_fields) %> <% conditional = field.conditional_visibility? %> <% if field.group_header? %>
@@ -107,10 +107,10 @@ <%= render "shared/badge", label: field.visibility_label, classes: visibility_badge_styles[field.visibility] %> <%= render "forms/option_source_link", field: field %>
- <%= render "events/public_registrations/form_field", field: field, value: nil, label: email_label_overrides[field.field_identifier], show_dropdown_warning: true %> + <%= render "events/public_registrations/form_field", field: field, asked: @form_fields, value: nil, label: email_label_overrides[field.field_identifier], show_dropdown_warning: true %>
<% else %> - <%= render "events/public_registrations/form_field", field: field, value: nil, label: email_label_overrides[field.field_identifier], show_option_source: true, show_dropdown_warning: true %> + <%= render "events/public_registrations/form_field", field: field, asked: @form_fields, value: nil, label: email_label_overrides[field.field_identifier], show_option_source: true, show_dropdown_warning: true %> <% end %>
<% end %> diff --git a/app/views/public_forms/show.html.erb b/app/views/public_forms/show.html.erb index db9142a78e..e2c5abc41b 100644 --- a/app/views/public_forms/show.html.erb +++ b/app/views/public_forms/show.html.erb @@ -41,13 +41,13 @@
<% @form_fields.each do |field| %> <%# Asked inside its _state question's "Outside USA?" box. %> - <% next if field.paired_with_state? %> + <% next if field.paired_with_state?(among: @form_fields) %> <% if field.group_header? %> <%= render "events/public_registrations/group_header", field: field %> <% else %> <% submitted_value = params.dig(:public_registration, :form_fields, field.id.to_s) || @prefill_values[field.id] %>
- <%= render "events/public_registrations/form_field", field: field, value: submitted_value, required: @form.requires_answer?(field) %> + <%= render "events/public_registrations/form_field", field: field, asked: @form_fields, value: submitted_value, required: @form.requires_answer?(field) %>
<% end %> <% end %> diff --git a/db/seeds/features/2026-10-25-outside-usa-license-states.yml b/db/seeds/features/2026-10-25-outside-usa-license-states.yml index 1654b24586..0099b5f4d4 100644 --- a/db/seeds/features/2026-10-25-outside-usa-license-states.yml +++ b/db/seeds/features/2026-10-25-outside-usa-license-states.yml @@ -10,6 +10,6 @@ pro_tips: - "Covers licenses on the person form, the CE page on the registration ticket, the admin license and CE registration pages, address and work-address forms, and state questions on public and registration forms." - "A typed state wins over the dropdown. Clear the box to go back to a US state." - "A license's state and country show together, e.g. \"Ontario, Canada\"." - - "A form question whose smart field ends in _country is asked inside the matching _state question's Outside USA? box — see Smart field settings." + - "When a form asks both, a question whose smart field ends in _country is asked inside the matching _state question's Outside USA? box — see Smart field settings." - "Left blank beside a US state, a country — on a license or a form answer — is saved as United States." - "The registration form's CE section asks which country issued the license." diff --git a/spec/models/form_field_spec.rb b/spec/models/form_field_spec.rb index bd09fbd7c0..a17407de64 100644 --- a/spec/models/form_field_spec.rb +++ b/spec/models/form_field_spec.rb @@ -183,22 +183,22 @@ let(:form) { create(:form) } let!(:state) { create(:form_field, form: form, field_identifier: "practice_state") } let!(:country) { create(:form_field, form: form, field_identifier: "practice_country") } + let(:asked) { [ state, country ] } - it "pairs a _country question with the same form's matching _state question" do - expect(state.paired_country_field).to eq(country) - expect(country).to be_paired_with_state + it "pairs a _country question with the matching _state question being asked" do + expect(state.paired_country_field(among: asked)).to eq(country) + expect(country.paired_with_state?(among: asked)).to be(true) end it "leaves a _country question unpaired without a matching _state question" do lone = create(:form_field, form: form, field_identifier: "organization_country") - expect(lone).not_to be_paired_with_state + expect(lone.paired_with_state?(among: asked + [ lone ])).to be(false) end - it "ignores an inactive country question" do - country.update!(status: :inactive) - - expect(state.paired_country_field).to be_nil + it "leaves a _country question unpaired when the state question isn't being asked" do + expect(country.paired_with_state?(among: [ country ])).to be(false) + expect(state.paired_country_field(among: [ state ])).to be_nil end end diff --git a/spec/requests/outside_usa_state_spec.rb b/spec/requests/outside_usa_state_spec.rb index 3e6a8d7cb4..5aac8724d8 100644 --- a/spec/requests/outside_usa_state_spec.rb +++ b/spec/requests/outside_usa_state_spec.rb @@ -140,6 +140,20 @@ def page_html expect(FormAnswer.find_by(form_field: country_field).submitted_answer).to eq("Canada") end + + it "asks the country on its own when the state question is hidden for a logged-in respondent" do + [ state_field, country_field ].each { |field| field.update!(visibility: :logged_out_only) } + person = create(:person) + create(:address, addressable: person, state: "CA", country: "United States") + sign_in create(:user, person: person) + + get public_form_path(form.slug) + + expect(page_html.at_css("select[name='public_registration[form_fields][#{state_field.id}]']")).to be_nil + country = page_html.at_css("select[name='public_registration[form_fields][#{country_field.id}]']") + expect(country).to be_present + expect(country.ancestors("details")).to be_empty + end end it "keeps a typed state in its box when the form comes back with errors" do diff --git a/spec/system/public_registration_form_submission_spec.rb b/spec/system/public_registration_form_submission_spec.rb index 69a2a2c477..242866692b 100644 --- a/spec/system/public_registration_form_submission_spec.rb +++ b/spec/system/public_registration_form_submission_spec.rb @@ -141,6 +141,11 @@ expect(page).not_to have_selector("##{pr_dom_id(reg_field('first_name'))}") expect(page).not_to have_selector("##{pr_dom_id(reg_field('mailing_city'))}") + # The mailing state is on file, so its paired country question — which would + # otherwise sit inside that state's "Outside USA?" box — is asked on its own. + expect(page).not_to have_selector("##{pr_dom_id(reg_field('mailing_state'))}") + select_pr reg_field("mailing_country"), "Canada" + select_pr reg_field("primary_sector"), "Education" check_pr_box reg_field("additional_sectors"), sector_dv.id.to_s select_pr reg_field("primary_age_group"), "Teens (13-17)" @@ -159,6 +164,7 @@ answers = answers_by_identifier(registration_form.form_submissions.find_by!(person: logged_in_person)) expect(answers).to include( + "mailing_country" => "Canada", "primary_sector" => sector_education.id.to_s, "additional_sectors" => sector_dv.id.to_s, "primary_age_group" => age_teens.id.to_s, From dfec1587fff3a7d4b5651334ee70058dfce9cc69 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 11:53:26 -0400 Subject: [PATCH 09/13] Name the licenses list's state column for where the license was issued It now shows the country beside a province, e.g. "Ontario, Canada". Co-Authored-By: Claude Opus 5.5 (1M context) --- .../professional_licenses_results.html.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/professional_licenses/professional_licenses_results.html.erb b/app/views/professional_licenses/professional_licenses_results.html.erb index c68fc69fc3..d7eb5f0310 100644 --- a/app/views/professional_licenses/professional_licenses_results.html.erb +++ b/app/views/professional_licenses/professional_licenses_results.html.erb @@ -14,7 +14,7 @@ Registrant <%= sort_link.call("kind", "Type") %> / <%= sort_link.call("number", "Number") %> - State + Issued in Status CE hours issued
(this year) CE hours issued
(all years) From 545ef57e7280da40d1e8366e63a752f0d2e46496 Mon Sep 17 00:00:00 2001 From: maebeale Date: Sun, 25 Oct 2026 12:05:12 -0400 Subject: [PATCH 10/13] Search licenses by location (#2852) * Search licenses by location Admins looking for licenses from a region had to scan the whole list. A Location box now matches where a license was issued and where its holder lives, taking a US state by name or code, a province, a country, or the start of a city. Co-Authored-By: Claude Opus 5.5 (1M context) * Link the location search feature entry to its PR Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- app/models/professional_license.rb | 17 +++++++++-- .../_search_boxes.html.erb | 5 ++++ .../professional_licenses_results.html.erb | 2 +- ...2026-10-25-search-licenses-by-location.yml | 13 ++++++++ spec/models/professional_license_spec.rb | 30 +++++++++++++++++++ spec/requests/professional_licenses_spec.rb | 22 ++++++++++++++ 6 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 db/seeds/features/2026-10-25-search-licenses-by-location.yml diff --git a/app/models/professional_license.rb b/app/models/professional_license.rb index bfc7620bd2..d6732031ce 100644 --- a/app/models/professional_license.rb +++ b/app/models/professional_license.rb @@ -26,8 +26,20 @@ class ProfessionalLicense < ApplicationRecord scope :of_kind, ->(kind) { where(kind: kind) } scope :expired, -> { where.not(expires_on: nil).where("expires_on < ?", Date.current) } scope :not_expired, -> { where("expires_on IS NULL OR expires_on >= ?", Date.current) } - - # Drives the admin index filters (registrant, kind, expiry status). The + # Issued in, or held by someone with a live address in, a state or province (a US + # state by name or code), a country, or a city starting with the text. + scope :located_in, ->(place) { + place = place.to_s.strip + state = AddressTranslation.new.state(place, country: nil) + holders = Address.active.where(addressable_type: "Person") + .where("addresses.state = :state OR addresses.country = :place OR addresses.city LIKE :city", + state:, place:, city: "#{sanitize_sql_like(place)}%") + .select(:addressable_id) + where("professional_licenses.issuing_state = :state OR professional_licenses.issuing_country = :place", state:, place:) + .or(where(person_id: holders)) + } + + # Drives the admin index filters (registrant, kind, location, expiry status). The # registrant filter is a free-text search over the holder's name (incl. legal # first name) and email, reusing Person's remote-search columns. def self.search_by_params(params) @@ -36,6 +48,7 @@ def self.search_by_params(params) results = results.where(person: Person.remote_search(params[:person_query]).reorder(nil)) end results = results.of_kind(params[:kind]) if params[:kind].present? + results = results.located_in(params[:location]) if params[:location].present? results = results.expired if params[:expired] == "yes" results = results.not_expired if params[:expired] == "no" results diff --git a/app/views/professional_licenses/_search_boxes.html.erb b/app/views/professional_licenses/_search_boxes.html.erb index 49ffcc63c5..bf91efe1fd 100644 --- a/app/views/professional_licenses/_search_boxes.html.erb +++ b/app/views/professional_licenses/_search_boxes.html.erb @@ -15,6 +15,11 @@ field_class: field_class, label_class: label_class, wrapper_class: "min-w-[200px] flex-1", input_options: { autocomplete: "off" } %> + <%= render "shared/filter_text", param: :location, label: "Location", placeholder: "City, state, province, or country", + field_class: field_class, label_class: label_class, wrapper_class: "min-w-[200px] flex-1", + tooltip: "Where the license was issued, or the city or state of the holder's address", + input_options: { autocomplete: "off" } %> +
<%= select_tag "kind", diff --git a/app/views/professional_licenses/professional_licenses_results.html.erb b/app/views/professional_licenses/professional_licenses_results.html.erb index d7eb5f0310..f164d636b3 100644 --- a/app/views/professional_licenses/professional_licenses_results.html.erb +++ b/app/views/professional_licenses/professional_licenses_results.html.erb @@ -1,4 +1,4 @@ -<% sort_base = params.permit(:person_query, :kind, :expired).to_h.symbolize_keys %> +<% sort_base = params.permit(:person_query, :kind, :location, :expired).to_h.symbolize_keys %> <% sort_link = sort_header_link(frame: "professional_licenses_results", path: ->(p) { professional_licenses_path(p) }, base: sort_base) %> <%= turbo_frame_tag :professional_licenses_results do %>
diff --git a/db/seeds/features/2026-10-25-search-licenses-by-location.yml b/db/seeds/features/2026-10-25-search-licenses-by-location.yml new file mode 100644 index 0000000000..33b5550803 --- /dev/null +++ b/db/seeds/features/2026-10-25-search-licenses-by-location.yml @@ -0,0 +1,13 @@ +name: "Search licenses by location" +area: people +display_status: admin_facing +released_on: 2026-10-25 +action_path: "/professional_licenses" +pr_number: 2852 +summary: >- + The licenses list has a Location search that finds licenses issued in, or held + by someone living in, a city, state, province, or country. +pro_tips: + - "Type a US state by name or code — California or CA both work." + - "A city matches from its start, so \"Oak\" finds Oakland." + - "Only a holder's current addresses count; retired ones are skipped." diff --git a/spec/models/professional_license_spec.rb b/spec/models/professional_license_spec.rb index f775efca82..0f853348ee 100644 --- a/spec/models/professional_license_spec.rb +++ b/spec/models/professional_license_spec.rb @@ -109,6 +109,36 @@ def attach_ce(paid: false) end end + describe ".located_in" do + let!(:issued_in_california) { create(:professional_license, number: "1", issuing_state: "CA") } + let!(:issued_in_canada) { create(:professional_license, number: "2", issuing_state: "Ontario", issuing_country: "Canada") } + let!(:held_in_oakland) do + create(:professional_license, number: "3", issuing_state: "NY").tap do |license| + create(:address, addressable: license.person, city: "Oakland", state: "CA", country: "United States") + end + end + + it "finds licenses issued in, or held by someone living in, a US state by name or code" do + expect(described_class.located_in("California")).to contain_exactly(issued_in_california, held_in_oakland) + expect(described_class.located_in("ca")).to contain_exactly(issued_in_california, held_in_oakland) + end + + it "finds licenses by province or country" do + expect(described_class.located_in("Ontario")).to contain_exactly(issued_in_canada) + expect(described_class.located_in("Canada")).to contain_exactly(issued_in_canada) + end + + it "finds licenses by the holder's city, from its start" do + expect(described_class.located_in("Oak")).to contain_exactly(held_in_oakland) + end + + it "ignores a holder's retired address" do + held_in_oakland.person.addresses.update_all(inactive: true) + + expect(described_class.located_in("Oakland")).to be_empty + end + end + describe "issuing country" do it "is the US for a US state with no country named" do expect(create(:professional_license, issuing_state: "CA", issuing_country: "").issuing_country).to eq("United States") diff --git a/spec/requests/professional_licenses_spec.rb b/spec/requests/professional_licenses_spec.rb index 838e425528..bc446cbafc 100644 --- a/spec/requests/professional_licenses_spec.rb +++ b/spec/requests/professional_licenses_spec.rb @@ -40,6 +40,28 @@ expect(response.body).not_to include("LIC-999") end + it "filters by location" do + create(:professional_license, number: "LIC-777", issuing_state: "Ontario", issuing_country: "Canada") + + get professional_licenses_path(location: "Canada"), + headers: { "Turbo-Frame" => "professional_licenses_results" } + + expect(response.body).to include("LIC-777") + expect(response.body).not_to include("LIC-555") + end + + it "combines location with the registrant filter" do + holder = create(:person, first_name: "Zephyrina", last_name: "Aldercott") + create(:professional_license, person: holder, number: "LIC-778", issuing_country: "Canada") + create(:professional_license, number: "LIC-779", issuing_country: "Canada") + + get professional_licenses_path(location: "Canada", person_query: "Zephyrina"), + headers: { "Turbo-Frame" => "professional_licenses_results" } + + expect(response.body).to include("LIC-778") + expect(response.body).not_to include("LIC-779") + end + it "filters by registrant name" do holder = create(:person, first_name: "Zephyrina", last_name: "Aldercott") create(:professional_license, person: holder, number: "LIC-444") From 737cd53ed15ed381be12954984e2d98672e284e0 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 14:05:29 -0400 Subject: [PATCH 11/13] Record a US-state license as a US license whatever country was left in the box The country dropdown only sits inside the Outside USA box, so a country left there after switching to a US state is stale and showed as "CA, Canada". Co-Authored-By: Claude Opus 5.5 (1M context) --- app/models/professional_license.rb | 4 ++-- spec/models/professional_license_spec.rb | 8 ++++++-- spec/requests/professional_licenses_spec.rb | 4 ++-- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/models/professional_license.rb b/app/models/professional_license.rb index d6732031ce..ec2cc2fef2 100644 --- a/app/models/professional_license.rb +++ b/app/models/professional_license.rb @@ -92,9 +92,9 @@ def used_for_ce? private - # A US state with no country named is a US license. + # A US state is a US license; the country only means something beside a state outside the USA. def default_issuing_country - self.issuing_country = Address::DEFAULT_COUNTRY if issuing_country.blank? && issuing_state.in?(Address::US_STATE_ABBREVIATIONS) + self.issuing_country = Address::DEFAULT_COUNTRY if issuing_state.in?(Address::US_STATE_ABBREVIATIONS) end def prevent_destroy_with_ce diff --git a/spec/models/professional_license_spec.rb b/spec/models/professional_license_spec.rb index 0f853348ee..3fbfcbc1e7 100644 --- a/spec/models/professional_license_spec.rb +++ b/spec/models/professional_license_spec.rb @@ -144,8 +144,12 @@ def attach_ce(paid: false) expect(create(:professional_license, issuing_state: "CA", issuing_country: "").issuing_country).to eq("United States") end - it "keeps a country that was named" do - expect(create(:professional_license, issuing_state: "CA", issuing_country: "Mexico").issuing_country).to eq("Mexico") + it "is the US for a US state whatever country was left in the box" do + expect(create(:professional_license, issuing_state: "CA", issuing_country: "Canada").issuing_country).to eq("United States") + end + + it "keeps the country named beside a state outside the USA" do + expect(create(:professional_license, issuing_state: "Ontario", issuing_country: "Canada").issuing_country).to eq("Canada") end it "stays blank for a state outside the USA" do diff --git a/spec/requests/professional_licenses_spec.rb b/spec/requests/professional_licenses_spec.rb index bc446cbafc..cf27ebdafd 100644 --- a/spec/requests/professional_licenses_spec.rb +++ b/spec/requests/professional_licenses_spec.rb @@ -52,8 +52,8 @@ it "combines location with the registrant filter" do holder = create(:person, first_name: "Zephyrina", last_name: "Aldercott") - create(:professional_license, person: holder, number: "LIC-778", issuing_country: "Canada") - create(:professional_license, number: "LIC-779", issuing_country: "Canada") + create(:professional_license, person: holder, number: "LIC-778", issuing_state: "Ontario", issuing_country: "Canada") + create(:professional_license, number: "LIC-779", issuing_state: "Ontario", issuing_country: "Canada") get professional_licenses_path(location: "Canada", person_query: "Zephyrina"), headers: { "Turbo-Frame" => "professional_licenses_results" } From ee0d27262273f9d2db85875d2161d809fba89913 Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 14:11:51 -0400 Subject: [PATCH 12/13] Cover a typed Outside USA state winning over a US state picked first, on every form Co-Authored-By: Claude Opus 5.5 (1M context) --- spec/models/profile_change_request_spec.rb | 4 ++ spec/requests/address_state_field_spec.rb | 11 +++ spec/requests/outside_usa_state_spec.rb | 77 +++++++++++++++++++++ spec/requests/professional_licenses_spec.rb | 8 +++ 4 files changed, 100 insertions(+) diff --git a/spec/models/profile_change_request_spec.rb b/spec/models/profile_change_request_spec.rb index 92601164d0..72bc27917e 100644 --- a/spec/models/profile_change_request_spec.rb +++ b/spec/models/profile_change_request_spec.rb @@ -246,6 +246,10 @@ it "takes the place of the dropdown's state" do expect(described_class.new(proposed_state: "", proposed_state_outside_usa: "Ontario").proposed_state).to eq("Ontario") end + + it "wins over a US state picked first" do + expect(described_class.new(proposed_state: "NV", proposed_state_outside_usa: "Ontario").proposed_state).to eq("Ontario") + end end describe "#auto_appliable?" do diff --git a/spec/requests/address_state_field_spec.rb b/spec/requests/address_state_field_spec.rb index fdc610e077..38c0f44bfe 100644 --- a/spec/requests/address_state_field_spec.rb +++ b/spec/requests/address_state_field_spec.rb @@ -63,6 +63,17 @@ def state_input expect(address.reload.state).to eq("Ontario") end + it "saves a typed state over a US state picked first" do + address = create(:address, addressable: person, state: "CA", country: "United States") + + patch person_path(person), params: { person: { addresses_attributes: { "0" => { + id: address.id, city: address.city, street_address: address.street_address, zip_code: address.zip_code, + state: "NV", state_outside_usa: "Ontario" + } } } } + + expect(address.reload.state).to eq("Ontario") + end + it "ignores admin=true" do create(:address, addressable: person, state: "CA", country: "United States") diff --git a/spec/requests/outside_usa_state_spec.rb b/spec/requests/outside_usa_state_spec.rb index 5aac8724d8..ff7592f4cb 100644 --- a/spec/requests/outside_usa_state_spec.rb +++ b/spec/requests/outside_usa_state_spec.rb @@ -50,6 +50,36 @@ def page_html expect(license.reload).to have_attributes(issuing_state: "Ontario", issuing_country: "Canada") end + it "saves a typed state and country over a US state picked first" do + license = create(:professional_license, person: person, issuing_state: "CA") + + patch person_path(person), params: { person: { professional_licenses_attributes: { "0" => { + id: license.id, issuing_state: "CA", issuing_state_outside_usa: "Ontario", issuing_country: "Canada" + } } } } + + expect(license.reload).to have_attributes(issuing_state: "Ontario", issuing_country: "Canada") + end + + it "leaves the country blank for a typed state with no country picked" do + license = create(:professional_license, person: person, issuing_state: "CA") + + patch person_path(person), params: { person: { professional_licenses_attributes: { "0" => { + id: license.id, issuing_state: "CA", issuing_state_outside_usa: "Ontario", issuing_country: "" + } } } } + + expect(license.reload).to have_attributes(issuing_state: "Ontario", issuing_country: be_blank) + end + + it "records the US for a US state with no country picked" do + license = create(:professional_license, person: person, issuing_state: "Ontario", issuing_country: "Canada") + + patch person_path(person), params: { person: { professional_licenses_attributes: { "0" => { + id: license.id, issuing_state: "NY", issuing_state_outside_usa: "", issuing_country: "" + } } } } + + expect(license.reload).to have_attributes(issuing_state: "NY", issuing_country: "United States") + end + it "adds a license that only has a typed state" do expect { patch person_path(person), params: { person: { professional_licenses_attributes: { "0" => { @@ -78,6 +108,20 @@ def page_html expect(license.reload).to have_attributes(issuing_state: "Quebec", issuing_country: "Canada") end + + it "saves a typed state over a US state picked first" do + post registration_ce_license_path(registration.slug), params: { license_number: "LIC-1", license_kind: "LCSW", + license_issuing_state: "CA", license_issuing_state_outside_usa: "Quebec", license_issuing_country: "" } + + expect(license.reload).to have_attributes(issuing_state: "Quebec", issuing_country: be_blank) + end + + it "records the US for a US state with no country picked" do + post registration_ce_license_path(registration.slug), params: { license_number: "LIC-1", license_kind: "LCSW", + license_issuing_state: "CA", license_issuing_state_outside_usa: "", license_issuing_country: "" } + + expect(license.reload).to have_attributes(issuing_state: "CA", issuing_country: "United States") + end end describe "the admin CE registration form" do @@ -120,6 +164,16 @@ def page_html expect(FormAnswer.find_by(form_field: state_field).submitted_answer).to eq("Ontario") end + it "records a typed state over a US state picked first" do + post public_form_path(form.slug), params: { public_registration: { + Honeypot::FIELD_NAME => "", + form_fields: { state_field.id.to_s => "CA" }, + form_fields_outside_usa: { state_field.id.to_s => "Ontario" } + } } + + expect(FormAnswer.find_by(form_field: state_field).submitted_answer).to eq("Ontario") + end + context "with a matching country question" do let!(:country_field) { create(:form_field, form: form, name: "Country", field_identifier: "mailing_country") } @@ -141,6 +195,29 @@ def page_html expect(FormAnswer.find_by(form_field: country_field).submitted_answer).to eq("Canada") end + it "leaves an optional country blank for a typed state over a US state picked first" do + country_field.update!(required: false) + + post public_form_path(form.slug), params: { public_registration: { + Honeypot::FIELD_NAME => "", + form_fields: { state_field.id.to_s => "CA", country_field.id.to_s => "" }, + form_fields_outside_usa: { state_field.id.to_s => "Ontario" } + } } + + expect(FormAnswer.find_by(form_field: state_field).submitted_answer).to eq("Ontario") + expect(FormAnswer.find_by(form_field: country_field)&.submitted_answer).to be_blank + end + + it "records the US for a US state with no country picked" do + post public_form_path(form.slug), params: { public_registration: { + Honeypot::FIELD_NAME => "", + form_fields: { state_field.id.to_s => "CA", country_field.id.to_s => "" }, + form_fields_outside_usa: { state_field.id.to_s => "" } + } } + + expect(FormAnswer.find_by(form_field: country_field).submitted_answer).to eq("United States") + end + it "asks the country on its own when the state question is hidden for a logged-in respondent" do [ state_field, country_field ].each { |field| field.update!(visibility: :logged_out_only) } person = create(:person) diff --git a/spec/requests/professional_licenses_spec.rb b/spec/requests/professional_licenses_spec.rb index cf27ebdafd..a62335084f 100644 --- a/spec/requests/professional_licenses_spec.rb +++ b/spec/requests/professional_licenses_spec.rb @@ -208,6 +208,14 @@ expect(license.person).to eq(person) end + it "saves a typed state and country over a US state picked first" do + patch professional_license_path(license), params: { + professional_license: { issuing_state: "NY", issuing_state_outside_usa: "Ontario", issuing_country: "Canada" } + } + + expect(license.reload).to have_attributes(issuing_state: "Ontario", issuing_country: "Canada") + end + it "does not let the registrant be reassigned on update" do other = create(:person) From 70f8bcb60ff49cf3ba66b64ea045d6fc24b91b9e Mon Sep 17 00:00:00 2001 From: Mae Beale Date: Sun, 25 Oct 2026 14:39:41 -0400 Subject: [PATCH 13/13] List US territories with the states, not as countries Puerto Rico and the other territories had to be typed in the Outside USA box or picked as a country, which treated US citizens as foreign. They now sit in every state dropdown A-Z (as USPS and USWDS list them), the toggle reads "Not on list?", and an imported territory country reads as the US. Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 4 ++-- .../ce_license_picker_controller.js | 2 +- app/helpers/geography_helper.rb | 14 ++++++++++--- app/models/form_field.rb | 2 +- app/services/address_translation.rb | 2 +- app/services/outside_usa_state.rb | 4 ++-- app/services/smart_form_fields.rb | 6 +++--- app/views/addresses/_state_field.html.erb | 2 +- .../_details_section.html.erb | 4 ++-- .../events/callouts/_callout_form.html.erb | 2 +- app/views/events/callouts/ce.html.erb | 4 ++-- .../_form_field_input.html.erb | 4 ++-- .../events/public_registrations/new.html.erb | 6 +++--- app/views/forms/show.html.erb | 2 +- .../professional_licenses/_form.html.erb | 2 +- .../_professional_license_fields.html.erb | 2 +- .../_work_address_fields.html.erb | 2 +- app/views/public_forms/show.html.erb | 2 +- app/views/shared/_outside_usa.html.erb | 2 +- db/seeds/dev/events_management.rb | 2 +- .../2026-10-25-outside-usa-license-states.yml | 6 +++--- ...26-10-25-us-territories-in-state-lists.yml | 12 +++++++++++ spec/helpers/geography_helper_spec.rb | 21 +++++++++++++++++++ spec/models/professional_license_spec.rb | 4 ++++ spec/requests/address_state_field_spec.rb | 4 ++-- spec/requests/outside_usa_state_spec.rb | 13 ++++++++++-- spec/services/address_translation_spec.rb | 6 ++++++ ...ublic_registration_form_submission_spec.rb | 2 +- 28 files changed, 99 insertions(+), 39 deletions(-) create mode 100644 db/seeds/features/2026-10-25-us-territories-in-state-lists.yml diff --git a/AGENTS.md b/AGENTS.md index 368cea96eb..36962c5b11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -274,7 +274,7 @@ comparison — add a rule if none fits (e.g. `PersonPolicy#own_profile?`). Conve - `FmAddressAttributes` — Turns an `FmAddress` row into `Address` attributes for any FileMaker import (street from `StreetNo` + lines, ZIP or its `PostalCodeLookup`, county from the archive's ZIP table read once, everything clipped to 255), with state/country/type/locality through `AddressTranslation`; nil when the row lacks a street, city or state. An `address_type:` overrides the row's (an organization's addresses are work); `#place(city:, state:, county:)` builds a street-less address for a place the archive records by city alone (a project). `#unmatched` gathers what it couldn't translate. Used by `PersonServices::ImportFromRolodex` (listed in `fm:import_people`'s report) and the FileMaker organizations/affiliations imports (ADR-0008) - `AddressDefaults` — The country, county and locality an address leaves blank, from its other parts: a US state → United States (`Address::DEFAULT_COUNTRY`), a US ZIP → its county (`FmPostalCode.county_for`, FileMaker's ZIP table, skipped when the ZIP is in another state), then `AddressLocality`. Used on create by `OrganizationServices::UpsertAddress` (both org-linking flows and public registration capture), `PersonServices::CaptureFromSubmission` and `ProfileChangeRequests::Apply`; `UpsertAddress` also fills a matched address's blank country/county and blank or Unknown locality, never replacing a value someone set - `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`, and through `AddressDefaults` for the rest — 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 mailing city inside the City of Los Angeles — Los Angeles or a neighborhood (`LA_CITY_MAILING_CITIES`) — as **LA City**, the rest of Los Angeles County as **LA County**, Orange County as **Orange County**, and any other California county by `CALIFORNIA_REGIONS` (the region most FileMaker projects in that county name, plus a hand-checked few) as **Northern/Central/Southern CA**. A state no US state answers to, with no country, is **Outside USA** (the forms only offer US states). Everything else stays **Unknown**: a California address with no or an unplaced county, and a US address whose state no US state answers to. 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 -- `OutsideUsaState` — Resolves a US-state dropdown + "Outside USA?" box pair: `.pick(dropdown, typed)` for raw params (CE callout, admin CE registration) and `.form_field_answers(params, scope)`, which lays `form_fields_outside_usa` answers over `form_fields` for every `_state` question (public forms, registration, callout forms). The box itself is `shared/_outside_usa` — a no-JS `
` +- `OutsideUsaState` — Resolves a US-state dropdown + "Not on list?" box pair: `.pick(dropdown, typed)` for raw params (CE callout, admin CE registration) and `.form_field_answers(params, scope)`, which lays `form_fields_outside_usa` answers over `form_fields` for every `_state` question (public forms, registration, callout forms). The box itself is `shared/_outside_usa` — a no-JS `
` - `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 - `PersonServices::ReconcilePrimaryDesignations` — After a person merge, keeps the survivor's own primary sector + primary age range and demotes the duplicate's, which would otherwise leave the kept person with two primaries and trip Person's single-primary validations. The survivor's pre-merge primary ids are captured in the people deduper's `merge_keeper` (they're indistinguishable once merged); reconciliation runs in `after_merge` @@ -439,7 +439,7 @@ end - `asset_picker` — Asset selection UI - `autosave` — Auto-save form state; optional `status` targets (`data-field-name` = the field's name) show "Saved" or "Not saved: " beside a field via `shared/_autosave_status` (dedupe preview, index notes, notification responded toggles) - `carousel` — Swiper-based carousels (opt-in `Autoplay` via the options value) -- `ce_license_picker` — Fill the CE license type/number/state/country/expiry fields from the picked license (or clear them for a new one), opening the "Outside USA?" box for a license issued abroad +- `ce_license_picker` — Fill the CE license type/number/state/country/expiry fields from the picked license (or clear them for a new one), opening the "Not on list?" box for a license issued abroad - `cocoon` — Nested form handling (cocoon gem) - `collection` — Filter form auto-submit with debounce - `column_toggle` — Toggle table column visibility diff --git a/app/frontend/javascript/controllers/ce_license_picker_controller.js b/app/frontend/javascript/controllers/ce_license_picker_controller.js index 8c5c6b1b0e..82e74a231f 100644 --- a/app/frontend/javascript/controllers/ce_license_picker_controller.js +++ b/app/frontend/javascript/controllers/ce_license_picker_controller.js @@ -4,7 +4,7 @@ import { Controller } from "@hotwired/stimulus" // type / number / state / country / expiry fields from that license (carried on each // option's data attributes) so the fields describe the selected license. Choosing // "Create new license" clears them for a fresh entry. A state the US list can't show -// goes in the "Outside USA?" box, opened along with any country on file. +// goes in the "Not on list?" box, opened along with any country on file. export default class extends Controller { static targets = ["select", "kind", "number", "state", "typedState", "country", "outsideUsa", "expires"] diff --git a/app/helpers/geography_helper.rb b/app/helpers/geography_helper.rb index d5cc0382bc..bc7c2170d2 100644 --- a/app/helpers/geography_helper.rb +++ b/app/helpers/geography_helper.rb @@ -3,6 +3,7 @@ def us_states [ [ "Alabama", "AL" ], [ "Alaska", "AK" ], + [ "American Samoa", "AS" ], [ "Arizona", "AZ" ], [ "Arkansas", "AR" ], [ "California", "CA" ], @@ -12,6 +13,7 @@ def us_states [ "District of Columbia", "DC" ], [ "Florida", "FL" ], [ "Georgia", "GA" ], + [ "Guam", "GU" ], [ "Hawaii", "HI" ], [ "Idaho", "ID" ], [ "Illinois", "IL" ], @@ -36,15 +38,18 @@ def us_states [ "New York", "NY" ], [ "North Carolina", "NC" ], [ "North Dakota", "ND" ], + [ "Northern Mariana Islands", "MP" ], [ "Ohio", "OH" ], [ "Oklahoma", "OK" ], [ "Oregon", "OR" ], [ "Pennsylvania", "PA" ], + [ "Puerto Rico", "PR" ], [ "Rhode Island", "RI" ], [ "South Carolina", "SC" ], [ "South Dakota", "SD" ], [ "Tennessee", "TN" ], [ "Texas", "TX" ], + [ "U.S. Virgin Islands", "VI" ], [ "Utah", "UT" ], [ "Vermont", "VT" ], [ "Virginia", "VA" ], @@ -59,7 +64,7 @@ def us_state?(abbreviation) us_states.any? { |_name, abbr| abbr == abbreviation } end - # A saved state the US-state dropdown can't show, which belongs in the "Outside USA?" box. + # A saved state the US-state dropdown can't show, which belongs in the "Not on list?" box. def outside_usa_state?(state) state.present? && !us_state?(state) end @@ -88,8 +93,11 @@ def state_select_options(current = nil) # Country dropdown options: the United States first, then every other country A–Z. # The US minor outlying islands are left out — nobody picks them for an address. + # Picked from the state / territory dropdown, so never offered as a country. + US_TERRITORY_CODES = %w[AS GU MP PR UM VI].freeze + COUNTRIES = ([ Address::DEFAULT_COUNTRY ] + ( - ISO3166::Country.all.map { |country| PLAIN_COUNTRY_NAMES[country.alpha2] || country.common_name } - - [ Address::DEFAULT_COUNTRY, "United States Minor Outlying Islands" ] + ISO3166::Country.all.reject { |country| country.alpha2.in?(US_TERRITORY_CODES) } + .map { |country| PLAIN_COUNTRY_NAMES[country.alpha2] || country.common_name } - [ Address::DEFAULT_COUNTRY ] ).sort).freeze end diff --git a/app/models/form_field.rb b/app/models/form_field.rb index 305b5f4f07..0d8310b29b 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -283,7 +283,7 @@ def grid_span_class end # A `_country` question is asked inside its `_state` question's - # "Outside USA?" box rather than on its own. Both read `among` — the questions + # "Not on list?" box rather than on its own. Both read `among` — the questions # being asked here — so a country whose state partner this page isn't asking # (hidden as already answered, or as logged-out-only) is still asked on its own. def paired_country_field(among:) diff --git a/app/services/address_translation.rb b/app/services/address_translation.rb index bb0c68dff1..3190a06192 100644 --- a/app/services/address_translation.rb +++ b/app/services/address_translation.rb @@ -130,7 +130,7 @@ def country_from_state(state) end def name_for(country) - return UNITED_STATES if ALIASES[country.common_name.downcase] == "US" + return UNITED_STATES if ALIASES[country.common_name.downcase] == "US" || country.alpha2.in?(GeographyHelper::US_TERRITORY_CODES) GeographyHelper::PLAIN_COUNTRY_NAMES[country.alpha2] || country.common_name end diff --git a/app/services/outside_usa_state.rb b/app/services/outside_usa_state.rb index c2b7580a8e..837c8fcdd1 100644 --- a/app/services/outside_usa_state.rb +++ b/app/services/outside_usa_state.rb @@ -1,4 +1,4 @@ -# Resolves a state submitted through shared/_outside_usa: the "Outside USA?" box's +# Resolves a state submitted through shared/_outside_usa: the "Not on list?" box's # typed value when filled, otherwise the US-state dropdown's pick. module OutsideUsaState SUFFIX = "_outside_usa" @@ -16,7 +16,7 @@ def self.form_field_answers(params, scope) with_us_countries(answers.deep_merge(filled(typed))) end - # A paired country question sits in the state's closed "Outside USA?" box, so + # A paired country question sits in the state's closed "Not on list?" box, so # someone who picked a US state leaves it blank; record the US for them. Pairs # within the submitted questions, which are the ones the form asked. def self.with_us_countries(answers) diff --git a/app/services/smart_form_fields.rb b/app/services/smart_form_fields.rb index a9ac11039c..a0a8d3369b 100644 --- a/app/services/smart_form_fields.rb +++ b/app/services/smart_form_fields.rb @@ -167,7 +167,7 @@ class SmartFormFields [ "ce_license_number", "License number", "Finds or creates the registrant's professional license. Left blank, a placeholder license is created so the CE registration still has something to hang on." ], [ "ce_license_kind", "License type", "Sets the license type, e.g. LCSW." ], [ "ce_license_issuing_state", "License issuing state", "Sets the state that issued the license." ], - [ "ce_license_issuing_country", "License issuing country", "Sets the country that issued the license. Asked inside the issuing state's \"Outside USA?\" box." ], + [ "ce_license_issuing_country", "License issuing country", "Sets the country that issued the license. Asked inside the issuing state's \"Not on list?\" box." ], [ "ce_license_expires_on", "License expiry", "Sets the license expiry date." ] ] }, @@ -196,9 +196,9 @@ class SmartFormFields # Endings that change how a question is asked, whatever comes before them. ENDINGS = [ - [ "…_state", "Asked as a US-state dropdown with an \"Outside USA?\" box for typing any other province or state." ], + [ "…_state", "Asked as a US state / territory dropdown with a \"Not on list?\" box for typing any other province or state." ], [ "…_country", "Asked as a country dropdown. When the form also asks the matching …_state question " \ - "(mailing_state with mailing_country, say), it moves inside that question's \"Outside USA?\" box." ] + "(mailing_state with mailing_country, say), it moves inside that question's \"Not on list?\" box." ] ].freeze # Per-identifier note rendered under an answer-only chip, for values surfaced in diff --git a/app/views/addresses/_state_field.html.erb b/app/views/addresses/_state_field.html.erb index 8353d4703c..e217f9bab7 100644 --- a/app/views/addresses/_state_field.html.erb +++ b/app/views/addresses/_state_field.html.erb @@ -9,7 +9,7 @@ <% else %>
<%= f.input :state, - label: "State", + label: "State / territory", as: :select, collection: state_select_options(address.default_state), include_blank: true, diff --git a/app/views/continuing_education_registrations/_details_section.html.erb b/app/views/continuing_education_registrations/_details_section.html.erb index 7536204df0..27c479bb03 100644 --- a/app/views/continuing_education_registrations/_details_section.html.erb +++ b/app/views/continuing_education_registrations/_details_section.html.erb @@ -46,10 +46,10 @@ class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %>
- + <%= f.select :license_issuing_state, us_states, - { selected: (license&.issuing_state unless outside_usa_state?(license&.issuing_state)), include_blank: "Select a state" }, + { selected: (license&.issuing_state unless outside_usa_state?(license&.issuing_state)), include_blank: "Select a state or territory" }, id: "continuing_education_registration_license_issuing_state", data: { "ce-license-picker-target": "state" }, class: "w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" %> diff --git a/app/views/events/callouts/_callout_form.html.erb b/app/views/events/callouts/_callout_form.html.erb index fc52dc55a1..ffdac25220 100644 --- a/app/views/events/callouts/_callout_form.html.erb +++ b/app/views/events/callouts/_callout_form.html.erb @@ -82,7 +82,7 @@
<% asked = form.form_fields.order(:position).to_a %> <% asked.each do |field| %> - <%# Asked inside its _state question's "Outside USA?" box. %> + <%# Asked inside its _state question's "Not on list?" box. %> <% next if field.paired_with_state?(among: asked) %> <% if field.group_header? %>

<%= field.name %>

diff --git a/app/views/events/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index bdda0252d4..4cd4c86e61 100644 --- a/app/views/events/callouts/ce.html.erb +++ b/app/views/events/callouts/ce.html.erb @@ -193,7 +193,7 @@
- + <%# US states dropdown, matching the public registration form's state field. @@ -206,7 +206,7 @@ id="license_issuing_state" class="mt-2 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm <%= us_state?(license_issuing_state) ? "text-gray-900" : "text-gray-400" %> shadow-sm focus:border-teal-500 focus:ring focus:ring-teal-200 focus:outline-none" > - + <% us_states.each do |name, abbr| %> 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 2ff077c8f9..3791c5e755 100644 --- a/app/views/events/public_registrations/_form_field_input.html.erb +++ b/app/views/events/public_registrations/_form_field_input.html.erb @@ -3,7 +3,7 @@ `asked` is every question on the page, which decides state/country pairing. %> <% param_scope = local_assigns.fetch(:param_scope, "public_registration") %> <% return if field.group_header? %> -<%# Asked inside its _state question's "Outside USA?" box instead. %> +<%# Asked inside its _state question's "Not on list?" box instead. %> <% return if field.paired_with_state?(among: asked) %> <%# Callers can force a field required (e.g. identity on a registration form); defaults to the field's own setting. %> <% required = local_assigns.fetch(:required, field.required) %> @@ -48,7 +48,7 @@