fixes #24918: add subregion dropdown to address editor

pull/543/head
MatthewTighe 2 years ago committed by mergify[bot]
parent 07d4a8599d
commit 3fab791980

@ -0,0 +1,158 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
package org.mozilla.fenix.settings.address
import androidx.annotation.StringRes
import androidx.annotation.VisibleForTesting
import mozilla.components.concept.storage.Address
import org.mozilla.fenix.R
internal const val DEFAULT_COUNTRY = "US"
/**
* Value type representing properties determined by the country used in an [Address].
* This data is meant to mirror the data currently represented on desktop here:
* https://searchfox.org/mozilla-central/source/toolkit/components/formautofill/addressmetadata/addressReferences.js
*
* This can be expanded to included things like a list of applicable states/provinces per country
* or the names that should be used for each form field.
*
* Note: Most properties here need to be kept in sync with the data in the above desktop
* address reference file in order to prevent duplications when sync is enabled. There are
* ongoing conversations about how best to share that data cross-platform, if at all.
* Some more detail: https://bugzilla.mozilla.org/show_bug.cgi?id=1769809
*
* Exceptions: [displayName] is a local property and stop-gap to a more robust solution.
*
* @property countryCode The country code used to lookup the address data. Should match desktop entries.
* @property displayName The name to display when selected.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal data class Country(
val countryCode: String,
val displayName: String,
@StringRes val subregionTitleResource: Int,
val subregions: List<String>,
)
internal object AddressUtils {
/**
* The current list of supported countries.
*/
val countries = mapOf(
"CA" to Country(
countryCode = "CA",
displayName = "Canada",
subregionTitleResource = R.string.addresses_province,
subregions = Subregions.CA,
),
"US" to Country(
countryCode = "US",
displayName = "United States",
subregionTitleResource = R.string.addresses_state,
subregions = Subregions.US,
),
)
/**
* Get the country code associated with a [Country.displayName], or the [DEFAULT_COUNTRY] code
* if the display name is not supported.
*/
fun getCountryCode(displayName: String) = countries.values.find {
it.displayName == displayName
}?.countryCode ?: DEFAULT_COUNTRY
}
/**
* Convert a [Country.displayName] to the associated country code.
*/
fun String.toCountryCode() = AddressUtils.getCountryCode(this)
private object Subregions {
// This data is meant to mirror the data currently represented on desktop here:
// https://searchfox.org/mozilla-central/source/toolkit/components/formautofill/addressmetadata/addressReferences.js
val CA = listOf(
"Alberta",
"British Columbia",
"Manitoba",
"New Brunswick",
"Newfoundland and Labrador",
"Northwest Territories",
"Nova Scotia",
"Nunavut",
"Ontario",
"Prince Edward Island",
"Quebec",
"Saskatchewan",
"Yukon",
)
// This data is meant to mirror the data currently represented on desktop here:
// https://searchfox.org/mozilla-central/source/toolkit/components/formautofill/addressmetadata/addressReferences.js
val US = listOf(
"Alabama",
"Alaska",
"American Samoa",
"Arizona",
"Arkansas",
"Armed Forces (AA)",
"Armed Forces (AE)",
"Armed Forces (AP)",
"California",
"Colorado",
"Connecticut",
"Delaware",
"District of Columbia",
"Florida",
"Georgia",
"Guam",
"Hawaii",
"Idaho",
"Illinois",
"Indiana",
"Iowa",
"Kansas",
"Kentucky",
"Louisiana",
"Maine",
"Marshall Islands",
"Maryland",
"Massachusetts",
"Michigan",
"Micronesia",
"Minnesota",
"Mississippi",
"Missouri",
"Montana",
"Nebraska",
"Nevada",
"New Hampshire",
"New Jersey",
"New Mexico",
"New York",
"North Carolina",
"North Dakota",
"Northern Mariana Islands",
"Ohio",
"Oklahoma",
"Oregon",
"Palau",
"Pennsylvania",
"Puerto Rico",
"Rhode Island",
"South Carolina",
"South Dakota",
"Tennessee",
"Texas",
"Utah",
"Vermont",
"Virgin Islands",
"Virginia",
"Washington",
"West Virginia",
"Wisconsin",
"Wyoming",
)
}

@ -6,11 +6,12 @@ package org.mozilla.fenix.settings.address.view
import android.content.Context
import android.content.DialogInterface
import android.view.View
import android.widget.AdapterView
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import mozilla.components.concept.storage.Address
import android.widget.ArrayAdapter
import androidx.annotation.VisibleForTesting
import mozilla.components.browser.state.search.RegionState
import mozilla.components.concept.storage.UpdatableAddressFields
import mozilla.components.support.ktx.android.view.hideKeyboard
@ -19,12 +20,19 @@ import org.mozilla.fenix.R
import org.mozilla.fenix.databinding.FragmentAddressEditorBinding
import org.mozilla.fenix.ext.placeCursorAtEnd
import org.mozilla.fenix.settings.address.AddressEditorFragment
import org.mozilla.fenix.settings.address.AddressUtils.countries
import org.mozilla.fenix.settings.address.Country
import org.mozilla.fenix.settings.address.DEFAULT_COUNTRY
import org.mozilla.fenix.settings.address.interactor.AddressEditorInteractor
internal const val DEFAULT_COUNTRY = "US"
import org.mozilla.fenix.settings.address.toCountryCode
/**
* Shows an address editor for adding or updating an address.
* An address editor for adding or updating an address.
*
* @param binding The binding used to display the view.
* @param interactor [AddressEditorInteractor] used to respond to any user interactions.
* @param region If the [RegionState] is available, it will be used to set the country when adding a new address.
* @param address An [Address] to edit.
*/
class AddressEditorView(
private val binding: FragmentAddressEditorBinding,
@ -33,36 +41,6 @@ class AddressEditorView(
private val address: Address? = null
) {
/**
* Value type representing properties determined by the country used in an [Address].
* This data is meant to mirror the data currently represented on desktop here:
* https://searchfox.org/mozilla-central/source/toolkit/components/formautofill/addressmetadata/addressReferences.js
*
* This can be expanded to included things like a list of applicable states/provinces per country
* or the names that should be used for each form field.
*
* Note: Most properties here need to be kept in sync with the data in the above desktop
* address reference file in order to prevent duplications when sync is enabled. There are
* ongoing conversations about how best to share that data cross-platform, if at all.
* Some more detail: https://bugzilla.mozilla.org/show_bug.cgi?id=1769809
*
* Exceptions: [displayName] is a local property and stop-gap to a more robust solution.
*
* @property key The country code used to lookup the address data. Should match desktop entries.
* @property displayName The name to display when selected.
*/
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal data class Country(
val key: String,
val displayName: String,
)
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal val countries = mapOf(
"CA" to Country("CA", "Canada"),
"US" to Country("US", "United States"),
)
/**
* Binds the view in the [AddressEditorFragment], using the current [Address] if available.
*/
@ -91,7 +69,6 @@ class AddressEditorView(
binding.streetAddressInput.setText(address.streetAddress)
binding.cityInput.setText(address.addressLevel2)
binding.stateInput.setText(address.addressLevel1)
binding.zipInput.setText(address.postalCode)
binding.deleteButton.apply {
@ -102,7 +79,7 @@ class AddressEditorView(
}
}
bindCountryDropdown()
bindDropdowns()
}
internal fun saveAddress() {
@ -116,7 +93,7 @@ class AddressEditorView(
streetAddress = binding.streetAddressInput.text.toString(),
addressLevel3 = "",
addressLevel2 = "",
addressLevel1 = "",
addressLevel1 = binding.subregionDropDown.selectedItem.toString(),
postalCode = binding.zipInput.text.toString(),
country = binding.countryDropDown.selectedItem.toString().toCountryCode(),
tel = binding.phoneInput.text.toString(),
@ -143,26 +120,59 @@ class AddressEditorView(
}.show()
}
private fun bindCountryDropdown() {
val adapter = ArrayAdapter<String>(
private fun bindDropdowns() {
val adapter = ArrayAdapter(
binding.root.context,
android.R.layout.simple_spinner_dropdown_item,
countries.values.map { it.displayName }
)
val selectedCountryKey = (address?.country ?: region?.home).takeIf {
it in countries.keys
} ?: DEFAULT_COUNTRY
var selectedPosition = -1
countries.values.forEachIndexed { index, country ->
if (country.key == selectedCountryKey) selectedPosition = index
adapter.add(country.displayName)
}
val selectedPosition = countries.values
.indexOfFirst { it.countryCode == selectedCountryKey }
.takeIf { it > 0 }
?: 0
binding.countryDropDown.adapter = adapter
binding.countryDropDown.setSelection(selectedPosition)
binding.countryDropDown.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(
parent: AdapterView<*>?,
view: View?,
position: Int,
id: Long
) {
val newCountryKey = binding.countryDropDown.selectedItem.toString().toCountryCode()
countries[newCountryKey]?.let { country ->
bindSubregionDropdown(country)
}
}
override fun onNothingSelected(p0: AdapterView<*>?) = Unit
}
countries[selectedCountryKey]?.let { country ->
bindSubregionDropdown(country)
}
}
private fun String.toCountryCode() = countries.values.find {
it.displayName == this
}?.key ?: DEFAULT_COUNTRY
private fun bindSubregionDropdown(country: Country) {
val subregions = country.subregions
val selectedSubregion = address?.addressLevel1?.takeIf { it in subregions }
?: subregions.first()
val adapter = ArrayAdapter(
binding.root.context,
android.R.layout.simple_spinner_dropdown_item,
country.subregions
)
val selectedPosition = subregions.indexOf(selectedSubregion).takeIf { it > 0 } ?: 0
binding.subregionDropDown.adapter = adapter
binding.subregionDropDown.setSelection(selectedPosition)
binding.subregionTitle.setText(country.subregionTitleResource)
}
}

@ -234,50 +234,40 @@
</com.google.android.material.textfield.TextInputLayout>
<!-- State -->
<TextView
android:id="@+id/state_title"
android:layout_width="wrap_content"
<!-- Subregion -->
<LinearLayout
android:id="@+id/subregion_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:gravity="center_vertical"
android:letterSpacing="0.05"
android:paddingStart="3dp"
android:paddingEnd="0dp"
android:text="@string/addresses_state"
android:textColor="?attr/textPrimary"
android:textSize="12sp"
android:labelFor="@id/state_input"
app:fontFamily="@font/metropolis_semibold"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/city_layout" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/state_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textColor="?attr/textPrimary"
app:hintEnabled="false"
app:layout_constraintHorizontal_bias="0.8"
app:layout_constraintEnd_toStartOf="@+id/zip_layout"
android:paddingEnd="3dp"
android:orientation="vertical"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/state_title">
app:layout_constraintTop_toBottomOf="@id/city_layout"
app:layout_constraintBottom_toTopOf="@id/zip_title">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/state_input"
android:layout_width="match_parent"
<TextView
android:id="@+id/subregion_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:fontFamily="sans-serif"
android:imeOptions="flagNoExtractUi"
android:letterSpacing="0.01"
android:lineSpacingExtra="8sp"
android:maxLines="1"
android:singleLine="true"
android:layout_marginTop="10dp"
android:textColor="?attr/textPrimary"
android:textSize="16sp" />
android:textSize="12sp"
android:labelFor="@id/subregion_drop_down"
app:fontFamily="@font/metropolis_semibold"
android:text="@string/addresses_state" />
</com.google.android.material.textfield.TextInputLayout>
<androidx.appcompat.widget.AppCompatSpinner
android:id="@+id/subregion_drop_down"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/textPrimary" />
</LinearLayout>
<!-- Zip -->
<TextView
@ -295,17 +285,16 @@
android:labelFor="@id/zip_input"
app:fontFamily="@font/metropolis_semibold"
app:layout_constraintStart_toStartOf="@+id/zip_layout"
app:layout_constraintTop_toBottomOf="@+id/city_layout" />
app:layout_constraintTop_toBottomOf="@+id/subregion_layout" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/zip_layout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:textColor="?attr/textPrimary"
app:hintEnabled="false"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/state_layout"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/zip_title">
<com.google.android.material.textfield.TextInputEditText
@ -331,7 +320,7 @@
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:paddingStart="3dp"
android:paddingEnd="0dp"
android:paddingEnd="3dp"
android:orientation="vertical"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
@ -341,6 +330,7 @@
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:textColor="?attr/textPrimary"
android:textSize="12sp"
android:labelFor="@id/country_drop_down"
@ -487,7 +477,7 @@
android:paddingEnd="12dp"
android:text="@string/addresses_save_button"
app:layout_constraintTop_toTopOf="@+id/cancel_button"
app:layout_constraintBottom_toBottomOf="@+id/cancel_button"
app:layout_constraintBottom_toBottomOf="@id/cancel_button"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -1567,8 +1567,10 @@
<string name="addresses_street_address">Street Address</string>
<!-- The header for the city of an address -->
<string name="addresses_city">City</string>
<!-- The header for the state of an address -->
<!-- The header for the subregion of an address when "state" should be used -->
<string name="addresses_state">State</string>
<!-- The header for the subregion of an address when "province" should be used -->
<string name="addresses_province">Province</string>
<!-- The header for the zip code of an address -->
<string name="addresses_zip">Zip</string>
<!-- The header for the country or region of an address -->

@ -13,8 +13,10 @@ import io.mockk.verify
import kotlinx.coroutines.runBlocking
import mozilla.components.browser.state.search.RegionState
import mozilla.components.concept.storage.Address
import mozilla.components.concept.storage.UpdatableAddressFields
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@ -23,7 +25,6 @@ import org.mozilla.fenix.databinding.FragmentAddressEditorBinding
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.settings.address.interactor.AddressEditorInteractor
import org.mozilla.fenix.settings.address.view.AddressEditorView
import org.mozilla.fenix.settings.address.view.DEFAULT_COUNTRY
@RunWith(FenixRobolectricTestRunner::class)
class AddressEditorViewTest {
@ -45,6 +46,63 @@ class AddressEditorViewTest {
addressEditorView = spyk(AddressEditorView(binding, interactor))
}
@Test
fun `GIVEN an existing address WHEN the save button is clicked THEN interactor updates address`() {
val country = AddressUtils.countries["US"]!!
val address = generateAddress(country = country.countryCode, addressLevel1 = country.subregions[0])
val addressEditorView = AddressEditorView(
binding = binding,
interactor = interactor,
address = address,
)
addressEditorView.bind()
addressEditorView.saveAddress()
val expected = UpdatableAddressFields(
givenName = address.givenName,
additionalName = address.additionalName,
familyName = address.familyName,
organization = "",
streetAddress = address.streetAddress,
addressLevel3 = "",
addressLevel2 = "",
addressLevel1 = address.addressLevel1,
postalCode = address.postalCode,
country = address.country,
tel = address.tel,
email = address.email,
)
verify { interactor.onUpdateAddress(address.guid, expected) }
}
@Test
fun `GIVEN a new address WHEN the save button is clicked THEN interactor saves new address`() {
val addressEditorView = AddressEditorView(
binding = binding,
interactor = interactor,
)
addressEditorView.bind()
addressEditorView.saveAddress()
val expected = UpdatableAddressFields(
givenName = "",
additionalName = "",
familyName = "",
organization = "",
streetAddress = "",
addressLevel3 = "",
addressLevel2 = "",
addressLevel1 = "Alabama",
postalCode = "",
country = "US",
tel = "",
email = "",
)
verify { interactor.onSaveAddress(expected) }
}
@Test
fun `WHEN the cancel button is clicked THEN interactor is called`() {
addressEditorView.bind()
@ -68,7 +126,7 @@ class AddressEditorViewTest {
addressEditorView.bind()
assertEquals("PostalCode", binding.zipInput.text.toString())
assertEquals("State", binding.stateInput.text.toString())
assertEquals(address.addressLevel1, binding.subregionDropDown.selectedItem.toString())
assertEquals("City", binding.cityInput.text.toString())
assertEquals("Street", binding.streetAddressInput.text.toString())
assertEquals("Family", binding.lastNameInput.text.toString())
@ -108,6 +166,72 @@ class AddressEditorViewTest {
verify { addressEditorView.showConfirmDeleteAddressDialog(view.context, "123") }
}
@Test
fun `GIVEN existing address with correct subregion and country WHEN subregion dropdown is bound THEN adapter sets subregion dropdown to address`() {
val address = generateAddress(country = "US", addressLevel1 = "Oregon")
val addressEditorView = AddressEditorView(
binding = binding,
interactor = interactor,
address = address,
)
addressEditorView.bind()
assertEquals("Oregon", binding.subregionDropDown.selectedItem.toString())
}
@Test
fun `GIVEN existing address subregion outside of country WHEN subregion dropdown is bound THEN dropdown defaults to first subregion entry for country`() {
val address = generateAddress(country = "CA", addressLevel1 = "Alabama")
val addressEditorView = AddressEditorView(
binding = binding,
interactor = interactor,
address = address,
)
addressEditorView.bind()
assertEquals("Alberta", binding.subregionDropDown.selectedItem.toString())
}
@Test
fun `GIVEN no existing address WHEN subregion dropdown is bound THEN dropdown defaults to first subregion of default country`() {
val addressEditorView = AddressEditorView(
binding = binding,
interactor = interactor,
)
addressEditorView.bind()
assertEquals("Alabama", binding.subregionDropDown.selectedItem.toString())
}
@Test
fun `WHEN country is changed THEN available subregions are updated`() {
val addressEditorView = AddressEditorView(
binding = binding,
interactor = interactor,
)
addressEditorView.bind()
assertEquals("Alabama", binding.subregionDropDown.selectedItem.toString())
binding.countryDropDown.setSelection(0)
assertNotEquals("Alabama", binding.subregionDropDown.selectedItem.toString())
}
@Test
fun `GIVEN existing address not in available countries WHEN view is bound THEN country and subregion dropdowns are set to default `() {
val address = generateAddress(country = "I AM NOT A COUNTRY", addressLevel1 = "I AM NOT A STATE")
val addressEditorView = AddressEditorView(
binding = binding,
interactor = interactor,
address = address,
)
addressEditorView.bind()
assertEquals("United States", binding.countryDropDown.selectedItem.toString())
assertEquals("Alabama", binding.subregionDropDown.selectedItem.toString())
}
@Test
fun `GIVEN existing address WHEN country dropdown is bound THEN adapter sets country dropdown to address`() {
val addressEditorView = spyk(
@ -119,7 +243,7 @@ class AddressEditorViewTest {
)
addressEditorView.bind()
assertEquals(addressEditorView.countries["CA"]?.displayName, binding.countryDropDown.selectedItem.toString())
assertEquals(AddressUtils.countries["CA"]?.displayName, binding.countryDropDown.selectedItem.toString())
}
@Test
@ -134,7 +258,7 @@ class AddressEditorViewTest {
)
addressEditorView.bind()
assertEquals(addressEditorView.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString())
assertEquals(AddressUtils.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString())
}
@Test
@ -147,7 +271,7 @@ class AddressEditorViewTest {
)
addressEditorView.bind()
assertEquals(addressEditorView.countries["CA"]?.displayName, binding.countryDropDown.selectedItem.toString())
assertEquals(AddressUtils.countries["CA"]?.displayName, binding.countryDropDown.selectedItem.toString())
}
@Test
@ -160,7 +284,7 @@ class AddressEditorViewTest {
)
addressEditorView.bind()
assertEquals(addressEditorView.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString())
assertEquals(AddressUtils.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString())
}
@Test
@ -173,10 +297,10 @@ class AddressEditorViewTest {
)
addressEditorView.bind()
assertEquals(addressEditorView.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString())
assertEquals(AddressUtils.countries[DEFAULT_COUNTRY]!!.displayName, binding.countryDropDown.selectedItem.toString())
}
private fun generateAddress(country: String = "US") = Address(
private fun generateAddress(country: String = "US", addressLevel1: String = "Oregon") = Address(
guid = "123",
givenName = "Given",
additionalName = "Additional",
@ -185,7 +309,7 @@ class AddressEditorViewTest {
streetAddress = "Street",
addressLevel3 = "Suburb",
addressLevel2 = "City",
addressLevel1 = "State",
addressLevel1 = addressLevel1,
postalCode = "PostalCode",
country = country,
tel = "Telephone",

Loading…
Cancel
Save