diff --git a/app/assets/javascripts/app/form-field-format.js b/app/assets/javascripts/app/form-field-format.js index 308d63cf687..f00ec070241 100644 --- a/app/assets/javascripts/app/form-field-format.js +++ b/app/assets/javascripts/app/form-field-format.js @@ -1,5 +1,6 @@ -import { PhoneFormatter, SocialSecurityNumberFormatter, TextField } from 'field-kit'; +import { SocialSecurityNumberFormatter, TextField } from 'field-kit'; import DateFormatter from './modules/date-formatter'; +import InternationalPhoneFormatter from './modules/international-phone-formatter'; import NumericFormatter from './modules/numeric-formatter'; import PersonalKeyFormatter from './modules/personal-key-formatter'; import ZipCodeFormatter from './modules/zip-code-formatter'; @@ -13,7 +14,7 @@ function formatForm() { ['.home_equity_line', new NumericFormatter()], ['.mfa', new NumericFormatter()], ['.mortgage', new NumericFormatter()], - ['.phone', new PhoneFormatter()], + ['.phone', new InternationalPhoneFormatter()], ['.personal-key', new PersonalKeyFormatter()], ['.ssn', new SocialSecurityNumberFormatter()], ['.zipcode', new ZipCodeFormatter()], diff --git a/app/assets/javascripts/app/modules/international-phone-formatter.js b/app/assets/javascripts/app/modules/international-phone-formatter.js new file mode 100644 index 00000000000..2b3ee0b47db --- /dev/null +++ b/app/assets/javascripts/app/modules/international-phone-formatter.js @@ -0,0 +1,69 @@ +import { Formatter } from 'field-kit'; +import { asYouType as AsYouType } from 'libphonenumber-js'; + +const fixCountryCodeSpacing = (text, countryCode) => { + // If the text is `+123456`, make it `+123 456` + if (text[countryCode.length + 1] !== ' ') { + return text.replace(`+${countryCode}`, `+${countryCode} `); + } + return text; +}; + +const getFormattedTextData = (text) => { + if (text === '1') { + text = '+1'; + } + + const asYouType = new AsYouType('US'); + let formattedText = asYouType.input(text); + const countryCode = asYouType.country_phone_code; + + if (asYouType.country_phone_code) { + formattedText = fixCountryCodeSpacing(formattedText, countryCode); + } + + return { + text: formattedText, + template: asYouType.template, + countryCode, + }; +}; + +const cursorPosition = (formattedTextData) => { + // If the text is `(23 )` the cursor goes after the 3 + const match = formattedTextData.text.match(/\d[^\d]*$/); + if (match) { + return match.index + 1; + } + return formattedTextData.text.length + 1; +}; + +class InternationalPhoneFormatter extends Formatter { + format(text) { + const formattedTextData = getFormattedTextData(text); + return super.format(formattedTextData.text); + } + + // eslint-disable-next-line class-methods-use-this + parse(text) { + return text.replace(/[^\d+]/g, ''); + } + + isChangeValid(change, error) { + const formattedTextData = getFormattedTextData(change.proposed.text); + const previousFormattedTextData = getFormattedTextData(change.current.text); + + if (previousFormattedTextData.template && + !formattedTextData.template && + change.inserted.text.length === 1 + ) { + return false; + } + + change.proposed.text = formattedTextData.text; + change.proposed.selectedRange.start = cursorPosition(formattedTextData); + return super.isChangeValid(change, error); + } +} + +export default InternationalPhoneFormatter; diff --git a/app/assets/javascripts/app/phone-internationalization.js b/app/assets/javascripts/app/phone-internationalization.js index 9e985a169c3..af3e4724a33 100644 --- a/app/assets/javascripts/app/phone-internationalization.js +++ b/app/assets/javascripts/app/phone-internationalization.js @@ -1,5 +1,7 @@ import { PhoneFormatter } from 'field-kit'; +const INTERNATIONAL_CODE_REGEX = /^\+(\d+) |^1 /; + const I18n = window.LoginGov.I18n; const phoneFormatter = new PhoneFormatter(); @@ -18,7 +20,7 @@ const areaCodeFromUSPhone = (phone) => { }; const selectedInternationCodeOption = () => { - const dropdown = document.querySelector('#two_factor_setup_form_international_code'); + const dropdown = document.querySelector('[data-international-phone-form] .international-code'); return dropdown.item(dropdown.selectedIndex); }; @@ -50,9 +52,14 @@ const unsupportedPhoneOTPDeliveryWarningMessage = (phone) => { }; const updateOTPDeliveryMethods = () => { - const phoneInput = document.querySelector('#two_factor_setup_form_phone'); const phoneRadio = document.querySelector('#two_factor_setup_form_otp_delivery_preference_voice'); const smsRadio = document.querySelector('#two_factor_setup_form_otp_delivery_preference_sms'); + + if (!phoneRadio || !smsRadio) { + return; + } + + const phoneInput = document.querySelector('[data-international-phone-form] .phone'); const phoneLabel = phoneRadio.parentNode.parentNode; const deliveryMethodHint = document.querySelector('#otp_delivery_preference_instruction'); const optPhoneLabelInfo = document.querySelector('#otp_phone_label_info'); @@ -74,14 +81,56 @@ const updateOTPDeliveryMethods = () => { } }; +const internationalCodeFromPhone = (phone) => { + const match = phone.match(INTERNATIONAL_CODE_REGEX); + if (match) { + return match[1] || match[2]; + } + return '1'; +}; + +const updateInternationalCodeSelection = () => { + const phoneInput = document.querySelector('[data-international-phone-form] .phone'); + const phone = phoneInput.value; + const internationalCode = internationalCodeFromPhone(phone); + const option = document.querySelector(`[data-country-code='${internationalCode}']`); + if (option) { + const dropdown = document.querySelector('[data-international-phone-form] .international-code'); + dropdown.value = option.value; + } +}; + +const updateInternationalCodeInPhone = (phone, newCode) => { + if (phone.match(/^\+[^d+]$/)) { + phone = phone.replace(/^\+[^d+]$/, ''); + } + if (phone.match(INTERNATIONAL_CODE_REGEX)) { + return phone.replace(INTERNATIONAL_CODE_REGEX, `+${newCode} `); + } + return `+${newCode} ${phone}`; +}; + +const updateInternationalCodeInput = () => { + const phoneInput = document.querySelector('[data-international-phone-form] .phone'); + const phone = phoneInput.value; + const inputInternationalCode = internationalCodeFromPhone(phone); + const selectedInternationalCode = selectedInternationCodeOption().dataset.countryCode; + + if (inputInternationalCode !== selectedInternationalCode) { + phoneInput.value = updateInternationalCodeInPhone(phone, selectedInternationalCode); + } +}; + document.addEventListener('DOMContentLoaded', () => { - const phoneInput = document.querySelector('#two_factor_setup_form_phone'); - const codeInput = document.querySelector('#two_factor_setup_form_international_code'); + const phoneInput = document.querySelector('[data-international-phone-form] .phone'); + const codeInput = document.querySelector('[data-international-phone-form] .international-code'); if (phoneInput) { phoneInput.addEventListener('keyup', updateOTPDeliveryMethods); + phoneInput.addEventListener('keyup', updateInternationalCodeSelection); } if (codeInput) { codeInput.addEventListener('change', updateOTPDeliveryMethods); + codeInput.addEventListener('change', updateInternationalCodeInput); updateOTPDeliveryMethods(); } }); diff --git a/app/views/users/phones/edit.html.slim b/app/views/users/phones/edit.html.slim index e2c3498a1fd..73ba00505a9 100644 --- a/app/views/users/phones/edit.html.slim +++ b/app/views/users/phones/edit.html.slim @@ -2,11 +2,15 @@ h1.h3.my0 = t('headings.edit_info.phone') -= simple_form_for(@update_user_phone_form, url: manage_phone_path, += simple_form_for(@update_user_phone_form, + data: { unsupported_area_codes: @unsupported_area_codes, + international_phone_form: true }, + url: manage_phone_path, html: { autocomplete: 'off', method: :put, role: 'form' }) do |f| = f.input :international_code, collection: international_phone_codes, - include_blank: false + include_blank: false, + input_html: { class: 'international-code' } = f.input :phone, as: :tel, required: true, input_html: { class: 'phone', value: nil }, label: t('account.index.phone') = f.button :submit, t('forms.buttons.submit.confirm_change'), class: 'mt2' diff --git a/app/views/users/two_factor_authentication_setup/index.html.slim b/app/views/users/two_factor_authentication_setup/index.html.slim index 27b0fc87370..2a86ed90b15 100644 --- a/app/views/users/two_factor_authentication_setup/index.html.slim +++ b/app/views/users/two_factor_authentication_setup/index.html.slim @@ -5,14 +5,16 @@ p.mt-tiny.mb0 = t('devise.two_factor_authentication.otp_setup_html') = simple_form_for(@two_factor_setup_form, html: { autocomplete: 'off', role: 'form' }, - data: { unsupported_area_codes: @unsupported_area_codes }, + data: { unsupported_area_codes: @unsupported_area_codes, + international_phone_form: true }, method: :patch, url: phone_setup_path) do |f| .clearfix .sm-col.sm-col-8 = f.input :international_code, collection: international_phone_codes, - include_blank: false + include_blank: false, + input_html: { class: 'international-code' } = f.label :phone, class: 'block' strong.left = t('devise.two_factor_authentication.otp_phone_label') span#otp_phone_label_info.ml1.italic diff --git a/package.json b/package.json index a0f99141f21..5e216e62d1e 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "field-kit": "^2.1.0", "focus-trap": "^2.3.0", "hint.css": "^2.3.2", + "libphonenumber-js": "^0.4.23", "normalize.css": "^4.2.0", "sinon": "^1.17.7", "zxcvbn": "^4.4.2" diff --git a/spec/features/idv/phone_spec.rb b/spec/features/idv/phone_spec.rb index 666eb327173..2d824ef3117 100644 --- a/spec/features/idv/phone_spec.rb +++ b/spec/features/idv/phone_spec.rb @@ -79,7 +79,7 @@ fill_in 'Phone', with: '' find('#idv_phone_form_phone').native.send_keys('abcd1234') - expect(find('#idv_phone_form_phone').value).to eq '1 (234) ' + expect(find('#idv_phone_form_phone').value).to eq '+1 234' end def complete_idv_profile_with_phone(phone) diff --git a/spec/features/two_factor_authentication/sign_in_spec.rb b/spec/features/two_factor_authentication/sign_in_spec.rb index 01c6f7fbc74..7ce7aff8dd1 100644 --- a/spec/features/two_factor_authentication/sign_in_spec.rb +++ b/spec/features/two_factor_authentication/sign_in_spec.rb @@ -92,7 +92,7 @@ scenario 'disables the phone option and displays a warning with js', :js do sign_in_before_2fa select 'Turkey +90', from: 'International code' - fill_in 'Phone', with: '555-555-5000' + fill_in 'Phone', with: '+90 312 213 29 65' phone_radio_button = page.find( '#two_factor_setup_form_otp_delivery_preference_voice', visible: :all @@ -112,6 +112,23 @@ ) expect(phone_radio_button).to_not be_disabled end + + scenario 'updates international code as user types', :js do + sign_in_before_2fa + fill_in 'Phone', with: '+81 54 354 3643' + + expect(page.find('#two_factor_setup_form_international_code').value).to eq 'JP' + + fill_in 'Phone', with: '5376' + select 'Morocco +212', from: 'International code' + + expect(find('#two_factor_setup_form_phone').value).to eq '+212 5376' + + fill_in 'Phone', with: '54354' + select 'Japan +81', from: 'International code' + + expect(find('#two_factor_setup_form_phone').value).to include '+81' + end end end diff --git a/spec/features/users/user_edit_spec.rb b/spec/features/users/user_edit_spec.rb index 41041acce6c..90810805be6 100644 --- a/spec/features/users/user_edit_spec.rb +++ b/spec/features/users/user_edit_spec.rb @@ -1,23 +1,47 @@ require 'rails_helper' feature 'User edit' do - scenario 'user sees error message if form is submitted without email', js: true, idv_job: true do - sign_in_and_2fa_user + context 'editing email' do + before do + sign_in_and_2fa_user + visit manage_email_path + end - visit manage_email_path - fill_in 'Email', with: '' - click_button 'Update' + scenario 'user sees error message if form is submitted without email', :js, idv_job: true do + fill_in 'Email', with: '' + click_button 'Update' - expect(page).to have_content t('valid_email.validations.email.invalid') + expect(page).to have_content t('valid_email.validations.email.invalid') + end end - scenario 'user sees error message if form is submitted without phone number', js: true do - sign_in_and_2fa_user + context 'editing 2FA phone number' do + before do + sign_in_and_2fa_user + visit manage_phone_path + end - visit manage_phone_path - fill_in 'Phone', with: '' - click_button t('forms.buttons.submit.confirm_change') + scenario 'user sees error message if form is submitted without phone number', js: true do + fill_in 'Phone', with: '' + click_button t('forms.buttons.submit.confirm_change') - expect(page).to have_content t('errors.messages.improbable_phone') + expect(page).to have_content t('errors.messages.improbable_phone') + end + + scenario 'updates international code as user types', :js do + fill_in 'Phone', with: '+81 54 354 3643' + + expect(page.find('#update_user_phone_form_international_code').value).to eq 'JP' + + fill_in 'Phone', with: '5376' + select 'Morocco +212', from: 'International code' + + expect(find('#update_user_phone_form_phone').value).to eq '+212 5376' + + fill_in 'Phone', with: '54354' + select 'Japan +81', from: 'International code' + + expect(find('#update_user_phone_form_phone').value).to include '+81' + end end end