diff --git a/AGENTS.md b/AGENTS.md index a2b06ae67b..36962c5b11 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 + "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` @@ -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 "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/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..82e74a231f 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 "Not on list?" 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..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" ], @@ -55,13 +60,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 "Not on list?" 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 @@ -79,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/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..b5e40fa26d --- /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/_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 + + 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..fe77ba4793 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 @@ -71,12 +72,12 @@ def self.search_by_params(params) # A license is matched by number + kind, so it may already carry a state and # expiry from an earlier event — only the values given here are written, since a # blank one means "not supplied", never "clear what's on file". - def self.enroll!(event_registration:, number: nil, kind: nil, issuing_state: nil, expires_on: nil) + def self.enroll!(event_registration:, number: nil, kind: nil, issuing_state: nil, issuing_country: nil, expires_on: nil) existing = event_registration.continuing_education_registrations.first return existing if existing license = ProfessionalLicense.find_or_create_for(person: event_registration.registrant, number: number, kind: kind) - attributes = { issuing_state: issuing_state, expires_on: expires_on }.compact + attributes = { issuing_state: issuing_state, issuing_country: issuing_country, expires_on: expires_on }.compact license.update!(attributes) if attributes.any? event_registration.continuing_education_registrations.create!(professional_license: license) end @@ -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/form_field.rb b/app/models/form_field.rb index 3aa9034ad3..0d8310b29b 100644 --- a/app/models/form_field.rb +++ b/app/models/form_field.rb @@ -142,6 +142,9 @@ class FormField < ApplicationRecord # explicit position and are left untouched. before_validation :append_to_bottom, on: :create, if: -> { position.blank? } + STATE_SUFFIX = "_state" + COUNTRY_SUFFIX = "_country" + # Enum enum :status, [ :inactive, :active ] enum :visibility, [ :always_ask, :scholarship_only, :logged_out_only, :answers_on_file ] @@ -279,6 +282,18 @@ def grid_span_class WIDTH_GRID_SPANS.fetch(width, "md:col-span-12") end + # A `_country` question is asked inside its `_state` question's + # "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:) + paired_field(STATE_SUFFIX, COUNTRY_SUFFIX, among) + end + + def paired_with_state?(among:) + paired_field(COUNTRY_SUFFIX, STATE_SUFFIX, among).present? + end + def answer_type_label ANSWER_TYPE_LABELS.fetch(answer_type, answer_type&.titleize) end @@ -593,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/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..ec2cc2fef2 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 @@ -13,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. @@ -21,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) } + # 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, expiry status). The + # 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) @@ -31,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 @@ -74,6 +92,11 @@ def used_for_ce? private + # 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_state.in?(Address::US_STATE_ABBREVIATIONS) + end + def prevent_destroy_with_ce return if removable? 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/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/event_registration_services/public_registration.rb b/app/services/event_registration_services/public_registration.rb index 2d184ad938..52d8c37019 100644 --- a/app/services/event_registration_services/public_registration.rb +++ b/app/services/event_registration_services/public_registration.rb @@ -19,6 +19,7 @@ class PublicRegistration # questions. Their answers fill in the ProfessionalLicense's issuing_state # and expires_on (both optional — registrants can supply them later). CE_LICENSE_ISSUING_STATE_IDENTIFIER = "ce_license_issuing_state".freeze + CE_LICENSE_ISSUING_COUNTRY_IDENTIFIER = "ce_license_issuing_country".freeze CE_LICENSE_EXPIRES_ON_IDENTIFIER = "ce_license_expires_on".freeze # Well-known field_identifier of the "Additional forms" multi-select question. @@ -259,7 +260,8 @@ def create_ce_registration(event_registration) ContinuingEducationRegistration.enroll!( event_registration: event_registration, number: ce_license_number, kind: ce_license_kind, - issuing_state: ce_license_issuing_state, expires_on: ce_license_expires_on + issuing_state: ce_license_issuing_state, issuing_country: ce_license_issuing_country, + expires_on: ce_license_expires_on ) end @@ -287,6 +289,12 @@ def ce_license_issuing_state ce_field_value(CE_LICENSE_ISSUING_STATE_IDENTIFIER)&.strip.presence end + def ce_license_issuing_country + return nil unless @continuing_education_form + + ce_field_value(CE_LICENSE_ISSUING_COUNTRY_IDENTIFIER)&.strip.presence + end + def ce_license_expires_on return nil unless @continuing_education_form diff --git a/app/services/form_builder_service.rb b/app/services/form_builder_service.rb index 8db2839acd..a51b4f6bca 100644 --- a/app/services/form_builder_service.rb +++ b/app/services/form_builder_service.rb @@ -136,6 +136,7 @@ def call "If seeking CE hours, what is your license type? (e.g. LMFT, LCSW, LPCC, LEP)", "What is your license number?", "In which state was your license issued?", + "In which country was your license issued?", "When does your license expire?" ], scholarship: [ @@ -627,6 +628,10 @@ def build_continuing_education_fields(form, position) "In which state was your license issued?", :free_form_input_one_line, key: "ce_license_issuing_state", group: "continuing_education", required: false) + position = add_field(form, position, + "In which country was your license issued?", + :free_form_input_one_line, + key: "ce_license_issuing_country", group: "continuing_education", required: false) position = add_field(form, position, "When does your license expire?", :free_form_input_one_line, diff --git a/app/services/outside_usa_state.rb b/app/services/outside_usa_state.rb new file mode 100644 index 0000000000..837c8fcdd1 --- /dev/null +++ b/app/services/outside_usa_state.rb @@ -0,0 +1,41 @@ +# 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" + + 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 + # and the US filled in for a paired country left blank beside a US state. + 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 || {} + with_us_countries(answers.deep_merge(filled(typed))) + end + + # 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) + 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(among: asked)&.id&.to_s + answers[country_key] = Address::DEFAULT_COUNTRY if country_key && answers[country_key].blank? + end + answers + end + private_class_method :with_us_countries + + 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/services/smart_form_fields.rb b/app/services/smart_form_fields.rb index 4b67d42bec..a0a8d3369b 100644 --- a/app/services/smart_form_fields.rb +++ b/app/services/smart_form_fields.rb @@ -167,6 +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 \"Not on list?\" box." ], [ "ce_license_expires_on", "License expiry", "Sets the license expiry date." ] ] }, @@ -193,6 +194,13 @@ class SmartFormFields event_rating most_valuable improvement_suggestions ].freeze + # Endings that change how a question is asked, whatever comes before them. + ENDINGS = [ + [ "…_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 \"Not on list?\" box." ] + ].freeze + # Per-identifier note rendered under an answer-only chip, for values surfaced in # a chart or report worth pointing an admin to. ANSWER_ONLY_NOTES = { diff --git a/app/views/addresses/_state_field.html.erb b/app/views/addresses/_state_field.html.erb index cb13c182d4..e217f9bab7 100644 --- a/app/views/addresses/_state_field.html.erb +++ b/app/views/addresses/_state_field.html.erb @@ -1,19 +1,30 @@ <% address = f.object.decorate %> <% if address.state_as_text? || (params[:admin] && allowed_to?(:enter_any_state?, Address)) %> <%= f.input :state, - label: "State / province", + label: "Province / State", input_html: { value: address.default_state, 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 / territory", + 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: "Province / State", + 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..27c479bb03 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 ), @@ -46,13 +46,24 @@ 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, 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" %> + <%= 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: "Province / State", aria: { label: "Province or state" }, 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/_callout_form.html.erb b/app/views/events/callouts/_callout_form.html.erb index 4c1011f5b5..ffdac25220 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 "Not on list?" 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?, + <%= 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/callouts/ce.html.erb b/app/views/events/callouts/ce.html.erb index 89593790c7..4cd4c86e61 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 || "—" %>
@@ -193,7 +193,7 @@
- + <%# US states dropdown, matching the public registration form's state field. @@ -204,14 +204,25 @@ + + <%= 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: "Province / State", aria: { label: "Province or state" }, + 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..3791c5e755 100644 --- a/app/views/events/public_registrations/_form_field_input.html.erb +++ b/app/views/events/public_registrations/_form_field_input.html.erb @@ -1,7 +1,10 @@ -<%# locals: (field:, value: nil, label: nil, show_option_source: false, show_dropdown_warning: false, param_scope: "public_registration", required: nil, resource: 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 "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) %> @@ -40,15 +43,50 @@ <% 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)) %> + <% 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) || + 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 %>" + value="<%= state if outside_usa_state?(state) %>" + 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 %>