Bug 1463545 - Replace grid layout of <address-option> with a new two line design. r=sfoster
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Thu, 20 Sep 2018 21:07:20 +0000
changeset 437506 1f44117fee2e
parent 437505 b6cc6d47b5be
child 437507 216c54f4650e
push id69702
push usermozilla@noorenberghe.ca
push dateThu, 20 Sep 2018 21:12:27 +0000
treeherderautoland@216c54f4650e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssfoster
bugs1463545
milestone64.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1463545 - Replace grid layout of <address-option> with a new two line design. r=sfoster Differential Revision: https://phabricator.services.mozilla.com/D5186
browser/components/payments/res/components/address-option.css
browser/components/payments/res/components/address-option.js
browser/components/payments/res/containers/address-picker.js
browser/components/payments/res/containers/payment-dialog.js
browser/components/payments/res/containers/rich-picker.js
browser/components/payments/res/debugging.js
browser/components/payments/res/mixins/ObservedPropertiesMixin.js
browser/components/payments/res/paymentRequest.xhtml
browser/components/payments/res/unprivileged-fallbacks.js
browser/components/payments/test/mochitest/mochitest.ini
browser/components/payments/test/mochitest/test_address_option.html
browser/components/payments/test/mochitest/test_address_picker.html
browser/components/payments/test/mochitest/test_payer_address_picker.html
browser/components/payments/test/mochitest/test_payment_dialog.html
browser/components/payments/test/mochitest/test_payment_dialog_required_top_level_items.html
browser/components/payments/test/mochitest/test_rich_select.html
browser/extensions/formautofill/content/autofillEditForms.js
--- a/browser/components/payments/res/components/address-option.css
+++ b/browser/components/payments/res/components/address-option.css
@@ -1,68 +1,29 @@
 /* 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/. */
 
-address-option {
+address-option.rich-option {
   grid-row-gap: 5px;
-  grid-column-gap: 10px;
-  grid-template-areas:
-    "name          "
-    "street-address";
-}
-
-rich-select[open] > .rich-select-popup-box > address-option {
-  grid-template-areas:
-    "name           name          "
-    "street-address street-address"
-    "email          tel           ";
-}
-
-address-picker.payer-related > rich-select address-option {
-  grid-template-areas:
-    "name name"
-    "tel email";
 }
 
-address-option > .name {
-  grid-area: name;
-}
-
-address-option > .street-address {
-  grid-area: street-address;
-}
-
-address-option > .email {
-  grid-area: email;
-}
-
-address-option > .tel {
-  grid-area: tel;
-}
-
-address-option > .name,
-address-option > .street-address,
-address-option > .email,
-address-option > .tel {
+address-option > .line {
+  overflow: hidden;
+  text-overflow: ellipsis;
   white-space: nowrap;
 }
 
-address-picker.shipping-related address-option > .email,
-address-picker.shipping-related address-option.rich-select-selected-option > .tel {
+address-option > .line:empty {
+  /* Hide the 2nd line in cases where it's empty
+     (e.g. payer field with one or two fields requested) */
   display: none;
 }
 
-/* for payer contact details:
- * display fields selectively based on the contents of the address-fields attribute
- */
-address-picker.payer-related address-option > .name,
-address-picker.payer-related address-option > .street-address,
-address-picker.payer-related address-option > .email,
-address-picker.payer-related address-option > .tel {
-  display: none;
+address-option > .line > span {
+  white-space: nowrap;
 }
 
-address-picker[address-fields~='name'].payer-related address-option  > .name,
-address-picker[address-fields~='email'].payer-related address-option  > .email,
-address-picker[address-fields~='tel'].payer-related address-option  > .tel {
-  display: inline-block;
+address-option > .line > span:empty::before {
+  /* Show the string for missing fields in grey when the field is empty */
+  color: GrayText;
+  content: attr(data-missing-string);
 }
--- a/browser/components/payments/res/components/address-option.js
+++ b/browser/components/payments/res/components/address-option.js
@@ -1,17 +1,21 @@
 /* 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/. */
 
+/* import-globals-from ../../../../../browser/extensions/formautofill/content/autofillEditForms.js*/
 import ObservedPropertiesMixin from "../mixins/ObservedPropertiesMixin.js";
 import RichOption from "./rich-option.js";
 /* import-globals-from ../unprivileged-fallbacks.js */
 
 /**
+ * Up to two-line address display. After bug 1475684 this will also be used for
+ * the single-line <option> substitute too.
+ *
  * <rich-select>
  *  <address-option guid="98hgvnbmytfc"
  *                  address-level1="MI"
  *                  address-level2="Some City"
  *                  email="foo@example.com"
  *                  country="USA"
  *                  name="Jared Wein"
  *                  postal-code="90210"
@@ -26,50 +30,121 @@ export default class AddressOption exten
   static get recordAttributes() {
     return [
       "address-level1",
       "address-level2",
       "country",
       "email",
       "guid",
       "name",
+      "organization",
       "postal-code",
       "street-address",
       "tel",
     ];
   }
 
   static get observedAttributes() {
-    return RichOption.observedAttributes.concat(AddressOption.recordAttributes);
+    return RichOption.observedAttributes.concat(AddressOption.recordAttributes,
+                                                "address-fields",
+                                                "break-after-nth-field",
+                                                "data-field-separator");
   }
 
   constructor() {
     super();
 
-    for (let name of ["name", "street-address", "email", "tel"]) {
+    this._line1 = document.createElement("div");
+    this._line1.classList.add("line");
+    this._line2 = document.createElement("div");
+    this._line2.classList.add("line");
+
+    for (let name of AddressOption.recordAttributes) {
       this[`_${name}`] = document.createElement("span");
       this[`_${name}`].classList.add(name);
+      // XXX Bug 1490816: Use appropriate strings
+      let missingValueString = name.replace(/(-|^)([a-z])/g, ($0, $1, $2) => {
+        return $1.replace("-", " ") + $2.toUpperCase();
+      }) + " Missing";
+      this[`_${name}`].dataset.missingString = missingValueString;
     }
   }
 
   connectedCallback() {
-    for (let name of ["name", "street-address", "email", "tel"]) {
-      this.appendChild(this[`_${name}`]);
-    }
+    this.appendChild(this._line1);
+    this.appendChild(this._line2);
     super.connectedCallback();
   }
 
   static formatSingleLineLabel(address, addressFields) {
     return PaymentDialogUtils.getAddressLabel(address, addressFields);
   }
 
+  get requiredFields() {
+    if (this.hasAttribute("address-fields")) {
+      let names = this.getAttribute("address-fields").trim().split(/\s+/);
+      if (names.length) {
+        return names;
+      }
+    }
+
+    return [
+      // "address-level1", // TODO: bug 1481481 - not required for some countries e.g. DE
+      "address-level2",
+      "country",
+      "name",
+      "postal-code",
+      "street-address",
+    ];
+  }
+
   render() {
+    // Clear the lines of the fields so we can append only the ones still
+    // visible in the correct order below.
+    this._line1.textContent = "";
+    this._line2.textContent = "";
+
+    // Fill the fields with their text/strings.
     // Fall back to empty strings to prevent 'null' from appearing.
-    this._name.textContent = this.name || "";
-    this["_street-address"].textContent =
-      `${this.streetAddress || ""} ${this.addressLevel2 || ""} ` +
-      `${this.addressLevel1 || ""} ${this.postalCode || ""} ${this.country || ""}`;
-    this._email.textContent = this.email || "";
-    this._tel.textContent = this.tel || "";
+    for (let name of AddressOption.recordAttributes) {
+      let camelCaseName = super.constructor.kebabToCamelCase(name);
+      let fieldEl = this[`_${name}`];
+      fieldEl.textContent = this[camelCaseName] || "";
+    }
+
+    let {fieldsOrder} = PaymentDialogUtils.getFormFormat(this.country);
+    // A subset of the requested fields may be returned if the fields don't apply to the country.
+    let requestedVisibleFields = this.addressFields || "mailing-address";
+    let visibleFields = EditAddress.computeVisibleFields(fieldsOrder, requestedVisibleFields);
+    let visibleFieldCount = 0;
+    let requiredFields = this.requiredFields;
+    // Start by populating line 1
+    let lineEl = this._line1;
+    // Which field number to start line 2 after.
+    let breakAfterNthField = this.breakAfterNthField || 2;
+
+    // Now actually place the fields in the proper place on the lines.
+    for (let field of visibleFields) {
+      let fieldEl = this[`_${field.fieldId}`];
+      if (!fieldEl) {
+        log.warn(`address-option render: '${field.fieldId}' doesn't exist`);
+        continue;
+      }
+
+      if (!fieldEl.textContent && !requiredFields.includes(field.fieldId)) {
+        // The field is empty and we don't need to show "Missing …" so don't append.
+        continue;
+      }
+
+      if (lineEl.children.length > 0) {
+        lineEl.append(this.dataset.fieldSeparator);
+      }
+      lineEl.appendChild(fieldEl);
+
+      // Add a break after this field, if requested.
+      if (++visibleFieldCount == breakAfterNthField) {
+        lineEl = this._line2;
+      }
+    }
   }
 }
 
 customElements.define("address-option", AddressOption);
--- a/browser/components/payments/res/containers/address-picker.js
+++ b/browser/components/payments/res/containers/address-picker.js
@@ -8,28 +8,36 @@ import paymentRequest from "../paymentRe
 
 /**
  * <address-picker></address-picker>
  * Container around add/edit links and <rich-select> with
  * <address-option> listening to savedAddresses & tempAddresses.
  */
 
 export default class AddressPicker extends RichPicker {
+  static get pickerAttributes() {
+    return [
+      "address-fields",
+      "break-after-nth-field",
+      "data-field-separator",
+    ];
+  }
+
   static get observedAttributes() {
-    return RichPicker.observedAttributes.concat(["address-fields"]);
+    return RichPicker.observedAttributes.concat(AddressPicker.pickerAttributes);
   }
 
   constructor() {
     super();
     this.dropdown.setAttribute("option-type", "address-option");
   }
 
   attributeChangedCallback(name, oldValue, newValue) {
     super.attributeChangedCallback(name, oldValue, newValue);
-    if (name == "address-fields" && oldValue !== newValue) {
+    if (AddressPicker.pickerAttributes.includes(name) && oldValue !== newValue) {
       this.render(this.requestStore.getState());
     }
   }
 
   get fieldNames() {
     if (this.hasAttribute("address-fields")) {
       let names = this.getAttribute("address-fields").trim().split(/\s+/);
       if (names.length) {
@@ -95,16 +103,30 @@ export default class AddressPicker exten
         let val = address[key];
         if (val) {
           optionEl.setAttribute(key, val);
         } else {
           optionEl.removeAttribute(key);
         }
       }
 
+      optionEl.dataset.fieldSeparator = this.dataset.fieldSeparator;
+
+      if (this.hasAttribute("address-fields")) {
+        optionEl.setAttribute("address-fields", this.getAttribute("address-fields"));
+      } else {
+        optionEl.removeAttribute("address-fields");
+      }
+
+      if (this.hasAttribute("break-after-nth-field")) {
+        optionEl.setAttribute("break-after-nth-field", this.getAttribute("break-after-nth-field"));
+      } else {
+        optionEl.removeAttribute("break-after-nth-field");
+      }
+
       // fieldNames getter is not used here because it returns a default array with
       // attributes even when "address-fields" observed attribute is null.
       let addressFields = this.getAttribute("address-fields");
       optionEl.textContent = AddressOption.formatSingleLineLabel(address, addressFields);
       desiredOptions.push(optionEl);
     }
 
     this.dropdown.popupBox.textContent = "";
--- a/browser/components/payments/res/containers/payment-dialog.js
+++ b/browser/components/payments/res/containers/payment-dialog.js
@@ -260,16 +260,49 @@ export default class PaymentDialog exten
         break;
       }
       default: {
         throw new Error(`Invalid completeStatus: ${completeStatus}`);
       }
     }
   }
 
+  _renderPayerFields(state) {
+    let paymentOptions = state.request.paymentOptions;
+    let payerRequested = this._isPayerRequested(paymentOptions);
+    for (let element of this._payerRelatedEls) {
+      element.hidden = !payerRequested;
+    }
+
+    if (payerRequested) {
+      let fieldNames = new Set(); // default: ["name", "tel", "email"]
+      if (paymentOptions.requestPayerName) {
+        fieldNames.add("name");
+      }
+      if (paymentOptions.requestPayerEmail) {
+        fieldNames.add("email");
+      }
+      if (paymentOptions.requestPayerPhone) {
+        fieldNames.add("tel");
+      }
+      this._payerAddressPicker.setAttribute("address-fields", [...fieldNames].join(" "));
+      // For the payer picker we want to have a line break after the name field (#1)
+      // if all three fields are requested.
+      if (fieldNames.size == 3) {
+        this._payerAddressPicker.setAttribute("break-after-nth-field", 1);
+      } else {
+        this._payerAddressPicker.removeAttribute("break-after-nth-field");
+      }
+    } else {
+      this._payerAddressPicker.removeAttribute("address-fields");
+    }
+    this._payerAddressPicker.dataset.addAddressTitle = this.dataset.payerTitleAdd;
+    this._payerAddressPicker.dataset.editAddressTitle = this.dataset.payerTitleEdit;
+  }
+
   stateChangeCallback(state) {
     super.stateChangeCallback(state);
 
     // Don't dispatch change events for initial selectedShipping* changes at initialization
     // if requestShipping is false.
     if (state.request.paymentOptions.requestShipping) {
       if (state.selectedShippingAddress != this._cachedState.selectedShippingAddress) {
         this.changeShippingAddress(state.selectedShippingAddress);
@@ -320,38 +353,18 @@ export default class PaymentDialog exten
       genericError = this._errorText.dataset[shippingType + "GenericError"];
     }
     this._errorText.textContent = paymentDetails.error || genericError;
 
     let paymentOptions = request.paymentOptions;
     for (let element of this._shippingRelatedEls) {
       element.hidden = !paymentOptions.requestShipping;
     }
-    let payerRequested = this._isPayerRequested(paymentOptions);
-    for (let element of this._payerRelatedEls) {
-      element.hidden = !payerRequested;
-    }
 
-    if (payerRequested) {
-      let fieldNames = new Set(); // default: ["name", "tel", "email"]
-      if (paymentOptions.requestPayerName) {
-        fieldNames.add("name");
-      }
-      if (paymentOptions.requestPayerEmail) {
-        fieldNames.add("email");
-      }
-      if (paymentOptions.requestPayerPhone) {
-        fieldNames.add("tel");
-      }
-      this._payerAddressPicker.setAttribute("address-fields", [...fieldNames].join(" "));
-    } else {
-      this._payerAddressPicker.removeAttribute("address-fields");
-    }
-    this._payerAddressPicker.dataset.addAddressTitle = this.dataset.payerTitleAdd;
-    this._payerAddressPicker.dataset.editAddressTitle = this.dataset.payerTitleEdit;
+    this._renderPayerFields(state);
 
     // hide the accepted cards list if the merchant didn't specify a preference
     let basicCardMethod = request.paymentMethods
       .find(method => method.supportedMethods == "basic-card");
     let merchantNetworks = basicCardMethod && basicCardMethod.data &&
                            basicCardMethod.data.supportedNetworks;
     this._acceptedCardsList.hidden = !(merchantNetworks && merchantNetworks.length);
 
--- a/browser/components/payments/res/containers/rich-picker.js
+++ b/browser/components/payments/res/containers/rich-picker.js
@@ -58,31 +58,38 @@ export default class RichPicker extends 
   render(state) {
     this.editLink.hidden = !this.dropdown.value;
 
     this.classList.toggle("invalid-selected-option",
                           !this.isSelectedOptionValid(state));
   }
 
   get selectedOption() {
-    return this.dropdown &&
-           this.dropdown.selectedOption;
+    return this.dropdown.selectedOption;
+  }
+
+  get selectedRichOption() {
+    return this.dropdown.selectedRichOption;
+  }
+
+  get requiredFields() {
+    return this.selectedOption ? this.selectedOption.requiredFields || [] : [];
   }
 
   get fieldNames() {
     return [];
   }
 
   isSelectedOptionValid() {
     return !this.missingFieldsOfSelectedOption().length;
   }
 
   missingFieldsOfSelectedOption() {
     let selectedOption = this.selectedOption;
     if (!selectedOption) {
       return [];
     }
 
-    let fieldNames = this.fieldNames;
+    let fieldNames = this.selectedRichOption.requiredFields;
     // Return all field names that are empty or missing from the option.
     return fieldNames.filter(name => !selectedOption.getAttribute(name));
   }
 }
--- a/browser/components/payments/res/debugging.js
+++ b/browser/components/payments/res/debugging.js
@@ -215,19 +215,36 @@ let ADDRESSES_1 = {
     "name": "Jane Z. Doe",
     "postal-code": "94041",
     "street-address": "P.O. Box 123",
     "tel": "+1 650 555-5555",
   },
   "abcde12345": {
     "address-level2": "Mountain View",
     "country": "US",
+    "family-name": "Fields",
+    "given-name": "Mrs.",
     "guid": "abcde12345",
     "name": "Mrs. Fields",
   },
+  "german1": {
+    "additional-name": "Y.",
+    "address-level1": "",
+    "address-level2": "Berlin",
+    "country": "DE",
+    "email": "de@example.com",
+    "family-name": "Mouse",
+    "given-name": "Anon",
+    "guid": "german1",
+    "name": "Anon Y. Mouse",
+    "organization": "Mozilla",
+    "postal-code": "10997",
+    "street-address": "Schlesische Str. 27",
+    "tel": "+49 30 983333002",
+  },
   "missing-country": {
     "address-level1": "ON",
     "address-level2": "Toronto",
     "family-name": "Bogard",
     "given-name": "Kristin",
     "guid": "missing-country",
     "name": "Kristin Bogard",
     "postal-code": "H0H 0H0",
--- a/browser/components/payments/res/mixins/ObservedPropertiesMixin.js
+++ b/browser/components/payments/res/mixins/ObservedPropertiesMixin.js
@@ -5,31 +5,35 @@
 /**
  * Define getters and setters for observedAttributes converted to camelCase and
  * trigger a batched aynchronous call to `render` upon observed
  * attribute/property changes.
  */
 
 export default function ObservedPropertiesMixin(superClass) {
   return class ObservedProperties extends superClass {
+    static kebabToCamelCase(name) {
+      return name.replace(/-([a-z])/g, ($0, $1) => $1.toUpperCase());
+    }
+
     constructor() {
       super();
 
       this._observedPropertiesMixin = {
         pendingRender: false,
       };
 
       // Reflect property changes for `observedAttributes` to attributes.
       for (let name of (this.constructor.observedAttributes || [])) {
         if (name in this) {
           // Don't overwrite existing properties.
           continue;
         }
         // Convert attribute names from kebab-case to camelCase properties
-        Object.defineProperty(this, name.replace(/-([a-z])/g, ($0, $1) => $1.toUpperCase()), {
+        Object.defineProperty(this, ObservedProperties.kebabToCamelCase(name), {
           configurable: true,
           get() {
             return this.getAttribute(name);
           },
           set(value) {
             if (value === null || value === undefined || value === false) {
               this.removeAttribute(name);
             } else {
--- a/browser/components/payments/res/paymentRequest.xhtml
+++ b/browser/components/payments/res/paymentRequest.xhtml
@@ -18,16 +18,17 @@
   <!ENTITY pickupAddressLabel         "Pickup Address">
   <!ENTITY shippingOptionsLabel       "Shipping Options">
   <!ENTITY deliveryOptionsLabel       "Delivery Options">
   <!ENTITY pickupOptionsLabel         "Pickup Options">
   <!ENTITY shippingGenericError       "Can’t ship to this address. Select a different address.">
   <!ENTITY deliveryGenericError       "Can’t deliver to this address. Select a different address.">
   <!ENTITY pickupGenericError         "Can’t pick up from this address. Select a different address.">
   <!ENTITY paymentMethodsLabel        "Payment Method">
+  <!ENTITY address.fieldSeparator     ", ">
   <!ENTITY address.addLink.label      "Add">
   <!ENTITY address.editLink.label     "Edit">
   <!ENTITY basicCard.addLink.label    "Add">
   <!ENTITY basicCard.editLink.label   "Edit">
   <!ENTITY payer.addLink.label        "Add">
   <!ENTITY payer.editLink.label       "Edit">
   <!ENTITY shippingAddress.addPage.title  "Add Shipping Address">
   <!ENTITY shippingAddress.editPage.title "Edit Shipping Address">
@@ -127,16 +128,17 @@
     </header>
 
     <div id="main-container">
       <payment-request-page id="payment-summary">
         <div class="page-body">
           <address-picker class="shipping-related"
                           data-add-link-label="&address.addLink.label;"
                           data-edit-link-label="&address.editLink.label;"
+                          data-field-separator="&address.fieldSeparator;"
                           data-shipping-address-label="&shippingAddressLabel;"
                           data-delivery-address-label="&deliveryAddressLabel;"
                           data-pickup-address-label="&pickupAddressLabel;"
                           data-invalid-label="&invalidOption.label;"
                           selected-state-key="selectedShippingAddress"></address-picker>
 
           <shipping-option-picker class="shipping-related"
                                   data-shipping-options-label="&shippingOptionsLabel;"
@@ -150,16 +152,17 @@
                                  data-invalid-label="&invalidOption.label;"
                                  label="&paymentMethodsLabel;">
           </payment-method-picker>
           <accepted-cards hidden="hidden" label="&acceptedCards.label;"></accepted-cards>
           <address-picker class="payer-related"
                           label="&payerLabel;"
                           data-add-link-label="&payer.addLink.label;"
                           data-edit-link-label="&payer.editLink.label;"
+                          data-field-separator="&address.fieldSeparator;"
                           data-invalid-label="&invalidOption.label;"
                           selected-state-key="selectedPayerAddress"></address-picker>
         </div>
 
         <footer>
           <span class="branding">&webPaymentsBranding.label;</span>
           <button id="cancel">&cancelPaymentButton.label;</button>
           <button id="pay"
--- a/browser/components/payments/res/unprivileged-fallbacks.js
+++ b/browser/components/payments/res/unprivileged-fallbacks.js
@@ -76,21 +76,21 @@ var PaymentDialogUtils = {
       };
     }
 
     return {
       "addressLevel1Label": country == "US" ? "state" : "province",
       "postalCodeLabel": country == "US" ? "zip" : "postalCode",
       "fieldsOrder": [
         {fieldId: "name", newLine: true},
-        {fieldId: "organization", newLine: true},
         {fieldId: "street-address", newLine: true},
         {fieldId: "address-level2"},
         {fieldId: "address-level1"},
         {fieldId: "postal-code"},
+        {fieldId: "organization"},
       ],
       // The following values come from addressReferences.js and should not be changed.
       /* eslint-disable-next-line max-len */
       "postalCodePattern": country == "US" ? "(\\d{5})(?:[ \\-](\\d{4}))?" : "[ABCEGHJKLMNPRSTVXY]\\d[ABCEGHJ-NPRSTV-Z] ?\\d[ABCEGHJ-NPRSTV-Z]\\d",
     };
   },
   getDefaultPreferences() {
     let prefValues = {
--- a/browser/components/payments/test/mochitest/mochitest.ini
+++ b/browser/components/payments/test/mochitest/mochitest.ini
@@ -8,16 +8,17 @@ support-files =
    ../../../../../browser/extensions/formautofill/skin/shared/editDialog-shared.css
    ../../../../../testing/modules/sinon-2.3.2.js
    ../../res/**
    payments_common.js
 skip-if = !e10s
 
 [test_accepted_cards.html]
 [test_address_form.html]
+[test_address_option.html]
 [test_address_picker.html]
 [test_basic_card_form.html]
 [test_basic_card_option.html]
 [test_completion_error_page.html]
 [test_currency_amount.html]
 [test_labelled_checkbox.html]
 [test_order_details.html]
 [test_payer_address_picker.html]
new file mode 100644
--- /dev/null
+++ b/browser/components/payments/test/mochitest/test_address_option.html
@@ -0,0 +1,179 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the address-option component
+-->
+<head>
+  <meta charset="utf-8">
+  <title>Test the address-option component</title>
+  <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="application/javascript" src="/tests/SimpleTest/AddTask.js"></script>
+  <script type="application/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
+  <script src="payments_common.js"></script>
+  <script src="../../res/vendor/custom-elements.min.js"></script>
+  <script src="../../res/unprivileged-fallbacks.js"></script>
+  <script src="autofillEditForms.js"></script>
+
+  <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
+  <link rel="stylesheet" type="text/css" href="../../res/components/address-option.css"/>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+  <p id="display">
+    <option id="option1"
+            data-field-separator=", "
+            address-level1="MI"
+            address-level2="Some City"
+            country="US"
+            email="foo@bar.com"
+            name="John Smith"
+            postal-code="90210"
+            street-address="123 Sesame Street,&#xA;Apt 40"
+            tel="+1 519 555-5555"
+            value="option1"
+            guid="option1"></option>
+    <option id="option2"
+            data-field-separator=", "
+            value="option2"
+            guid="option2"></option>
+
+    <rich-select id="richSelect1"
+                 option-type="address-option"></rich-select>
+  </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+/** Test the address-option component **/
+
+import "../../res/components/address-option.js";
+import "../../res/components/rich-select.js";
+
+let option1 = document.getElementById("option1");
+let option2 = document.getElementById("option2");
+let richSelect1 = document.getElementById("richSelect1");
+
+add_task(async function test_populated_option_rendering() {
+  richSelect1.popupBox.appendChild(option1);
+  richSelect1.value = option1.value;
+  await asyncElementRendered();
+
+  let richOption = richSelect1.selectedRichOption;
+
+  is(richOption.name, "John Smith", "Check name getter");
+  is(richOption.streetAddress, "123 Sesame Street,\nApt 40", "Check streetAddress getter");
+  is(richOption.addressLevel2, "Some City", "Check addressLevel2 getter");
+
+  ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+  ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+  ok(!richOption._line1.innerText.trim().endsWith(","), "Line 1 should not end with a comma");
+  ok(!richOption._line2.innerText.trim().endsWith(","), "Line 2 should not end with a comma");
+  is(richOption._line1.innerText, "John Smith, 123 Sesame Street, Apt 40", "Line 1 text");
+  is(richOption._line2.innerText, "Some City, MI, 90210, US", "Line 2 text");
+
+  // Note that innerText takes visibility into account so that's why it's used over textContent here
+  is(richOption._name.innerText, "John Smith", "name text");
+  is(richOption["_street-address"].innerText, "123 Sesame Street, Apt 40", "street-address text");
+  is(richOption["_address-level2"].innerText, "Some City", "address-level2 text");
+
+  is(richOption._email.parentElement, null,
+     "Check email field isn't in the document for a mailing-address option");
+});
+
+// Same option as the last test but with @break-after-nth-field=1
+add_task(async function test_breakAfterNthField() {
+  richSelect1.popupBox.appendChild(option1);
+  richSelect1.value = option1.value;
+  await asyncElementRendered();
+
+  let richOption = richSelect1.selectedRichOption;
+  richOption.breakAfterNthField = 1;
+  await asyncElementRendered();
+
+  ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+  ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+  ok(!richOption._line1.innerText.trim().endsWith(","), "Line 1 should not end with a comma");
+  ok(!richOption._line2.innerText.trim().endsWith(","), "Line 2 should not end with a comma");
+  is(richOption._line1.innerText, "John Smith", "Line 1 text with breakAfterNthField = 1");
+  is(richOption._line2.innerText, "123 Sesame Street, Apt 40, Some City, MI, 90210, US",
+     "Line 2 text with breakAfterNthField = 1");
+});
+
+add_task(async function test_addressField_mailingAddress() {
+  richSelect1.popupBox.appendChild(option1);
+  richSelect1.value = option1.value;
+  await asyncElementRendered();
+
+  let richOption = richSelect1.selectedRichOption;
+  richOption.addressFields = "mailing-address";
+  await asyncElementRendered();
+  is(richOption.getAttribute("address-fields"), "mailing-address", "Check @address-fields");
+
+  ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+  ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+  ok(!richOption._line1.innerText.trim().endsWith(","), "Line 1 should not end with a comma");
+  ok(!richOption._line2.innerText.trim().endsWith(","), "Line 2 should not end with a comma");
+  is(richOption._line1.innerText, "John Smith, 123 Sesame Street, Apt 40", "Line 1 text");
+  is(richOption._line2.innerText, "Some City, MI, 90210, US", "Line 2 text");
+
+  ok(!isHidden(richOption._line2), "Line 2 should be visible when it's used");
+
+  is(richOption._email.parentElement, null,
+     "Check email field isn't in the document for a mailing-address option");
+});
+
+add_task(async function test_addressField_nameEmail() {
+  richSelect1.popupBox.appendChild(option1);
+  richSelect1.value = option1.value;
+  await asyncElementRendered();
+
+  let richOption = richSelect1.selectedRichOption;
+  richOption.addressFields = "name email";
+  await asyncElementRendered();
+  is(richOption.getAttribute("address-fields"), "name email", "Check @address-fields");
+
+  ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+  ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+  ok(!richOption._line1.innerText.trim().endsWith(","), "Line 1 should not end with a comma");
+  ok(!richOption._line2.innerText.trim().endsWith(","), "Line 2 should not end with a comma");
+  is(richOption._line1.innerText, "John Smith, foo@bar.com", "Line 1 text");
+  is(richOption._line2.innerText, "", "Line 2 text");
+
+  ok(isHidden(richOption._line2), "Line 2 should be hidden when it's not used");
+
+  isnot(richOption._email.parentElement, null,
+        "Check email field is in the document for a 'name email' option");
+});
+
+add_task(async function test_missing_fields_option_rendering() {
+  richSelect1.popupBox.appendChild(option2);
+  richSelect1.value = option2.value;
+  await asyncElementRendered();
+
+  let richOption = richSelect1.selectedRichOption;
+  is(richOption.name, null, "Check name getter");
+  is(richOption.streetAddress, null, "Check streetAddress getter");
+  is(richOption.addressLevel2, null, "Check addressLevel2 getter");
+
+  ok(!richOption.innerText.includes("undefined"), "Check for presence of 'undefined'");
+  ok(!richOption.innerText.includes("null"), "Check for presence of 'null'");
+
+  is(richOption._name.innerText, "", "name text");
+  is(window.getComputedStyle(richOption._name, "::before").content, "attr(data-missing-string)",
+     "Check missing field pseudo content");
+  is(richOption._name.getAttribute("data-missing-string"), "Name Missing",
+     "Check @data-missing-string");
+  is(richOption._email.parentElement, null,
+     "Check email field isn't in the document for a mailing-address option");
+});
+
+</script>
+
+</body>
+</html>
--- a/browser/components/payments/test/mochitest/test_address_picker.html
+++ b/browser/components/payments/test/mochitest/test_address_picker.html
@@ -7,25 +7,27 @@ Test the address-picker component
   <meta charset="utf-8">
   <title>Test the address-picker component</title>
   <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/AddTask.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
   <script src="payments_common.js"></script>
   <script src="../../res/vendor/custom-elements.min.js"></script>
   <script src="../../res/unprivileged-fallbacks.js"></script>
+  <script src="autofillEditForms.js"></script>
 
   <link rel="stylesheet" type="text/css" href="../../res/containers/rich-picker.css"/>
   <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
   <link rel="stylesheet" type="text/css" href="../../res/components/address-option.css"/>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
 </head>
 <body>
   <p id="display">
     <address-picker id="picker1"
+                    data-field-separator=", "
                     selected-state-key="selectedShippingAddress"></address-picker>
   </p>
 <div id="content" style="display: none">
 
 </div>
 <pre id="test">
 </pre>
 <script type="module">
@@ -157,33 +159,33 @@ add_task(async function test_change_sele
   is(selectedOption, options[1], "Selected option should now be the second option");
   selectedShippingAddress = picker1.requestStore.getState().selectedShippingAddress;
   is(selectedShippingAddress, selectedOption.getAttribute("guid"),
      "store should have second option selected");
   ok(!picker1.classList.contains("invalid-selected-option"), "The second option has all fields");
   ok(isHidden(picker1.invalidLabel), "The invalid label should be hidden");
 });
 
-add_task(async function test_streetAddress_combines_street_level2_level1_postalCode_country() {
+add_task(async function test_address_combines_name_street_level2_level1_postalCode_country() {
   let options = picker1.dropdown.popupBox.children;
   let richoption1 = picker1.dropdown.querySelector(".rich-select-selected-option");
-  let streetAddress = richoption1.querySelector(".street-address");
   /* eslint-disable max-len */
-  is(streetAddress.textContent,
-     `${options[1].getAttribute("street-address")} ${options[1].getAttribute("address-level2")} ${options[1].getAttribute("address-level1")} ${options[1].getAttribute("postal-code")} ${options[1].getAttribute("country")}`,
+  is(richoption1.innerText,
+     `${options[1].getAttribute("name")}, ${options[1].getAttribute("street-address")}
+${options[1].getAttribute("address-level2")}, ${options[1].getAttribute("address-level1")}, ${options[1].getAttribute("postal-code")}, ${options[1].getAttribute("country")}`,
      "The address shown should be human readable and include all fields");
   /* eslint-enable max-len */
 
   picker1.dropdown.popupBox.focus();
   synthesizeKey(options[2].getAttribute("name"), {});
   await asyncElementRendered();
 
   richoption1 = picker1.dropdown.querySelector(".rich-select-selected-option");
-  streetAddress = richoption1.querySelector(".street-address");
-  is(streetAddress.textContent, " Mountain View   US",
+  // "Missing …" text is rendered via a pseudo element content and isn't included in innerText
+  is(richoption1.innerText, "Mrs. Fields, \nMountain View, , US",
      "The address shown should be human readable and include all fields");
 
   picker1.dropdown.popupBox.focus();
   synthesizeKey(options[1].getAttribute("name"), {});
   await asyncElementRendered();
 });
 
 add_task(async function test_delete() {
--- a/browser/components/payments/test/mochitest/test_payer_address_picker.html
+++ b/browser/components/payments/test/mochitest/test_payer_address_picker.html
@@ -7,24 +7,25 @@ Test the paymentOptions address-picker
   <meta charset="utf-8">
   <title>Test the paymentOptions address-picker</title>
   <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/AddTask.js"></script>
   <script src="payments_common.js"></script>
 
   <script src="../../res/vendor/custom-elements.min.js"></script>
   <script src="../../res/unprivileged-fallbacks.js"></script>
+  <script src="autofillEditForms.js"></script>
 
   <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
   <link rel="stylesheet" type="text/css" href="../../res/components/address-option.css"/>
   <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
 </head>
 <body>
-  <p id="display">
+  <p id="display" style="height: 100vh; margin: 0;">
     <iframe id="templateFrame" src="../../res/paymentRequest.xhtml" width="0" height="0"></iframe>
   </p>
 <div id="content" style="display: none">
 
 </div>
 <pre id="test">
 </pre>
 <script type="module">
@@ -121,21 +122,17 @@ add_task(async function setup_once() {
   registerConsoleFilter(function consoleFilter(msg) {
     return msg.errorMessage.includes("selectedPayerAddress option a9e830667189 does not exist");
   });
 
   let templateFrame = document.getElementById("templateFrame");
   await SimpleTest.promiseFocus(templateFrame.contentWindow);
 
   let displayEl = document.getElementById("display");
-  // Import the templates from the real shipping dialog to avoid duplication.
-  for (let template of templateFrame.contentDocument.querySelectorAll("template")) {
-    let imported = document.importNode(template, true);
-    displayEl.appendChild(imported);
-  }
+  importDialogDependencies(templateFrame, displayEl);
 
   elDialog = new PaymentDialog();
   displayEl.appendChild(elDialog);
   elPicker = elDialog.querySelector("address-picker.payer-related");
 
   let {request} = elDialog.requestStore.getState();
   initialState = Object.assign({}, {
     changesPrevented: false,
@@ -188,30 +185,30 @@ add_task(async function test_visible_fie
   is(elPicker.dropdown.popupBox.children.length, 2, "Check dropdown has 2 addresses");
   is(closedRichOption.getAttribute("guid"), "48bnds6854t", "expected option is visible");
 
   for (let fieldName of ["name", "email", "tel"]) {
     let elem = closedRichOption.querySelector(`.${fieldName}`);
     ok(elem, `field ${fieldName} exists`);
     ok(isVisible(elem), `field ${fieldName} is visible`);
   }
-  ok(!isVisible(closedRichOption.querySelector(".street-address")),
-     "street-address is not visible");
+  ok(!closedRichOption.querySelector(".street-address"), "street-address element is not present");
 });
 
 add_task(async function test_selective_fields() {
   await setup();
   let requestStore = elPicker.requestStore;
 
   requestStore.setState({
     savedAddresses: SAVED_ADDRESSES,
     selectedPayerAddress: "48bnds6854t",
   });
 
   let payerFieldVariations = [
+    {requestPayerName: true, requestPayerEmail: true, requestPayerPhone: true },
     {requestPayerName: true, requestPayerEmail: false, requestPayerPhone: false },
     {requestPayerName: false, requestPayerEmail: true, requestPayerPhone: false },
     {requestPayerName: false, requestPayerEmail: false, requestPayerPhone: true },
     {requestPayerName: true, requestPayerEmail: true, requestPayerPhone: false },
     {requestPayerName: false, requestPayerEmail: true, requestPayerPhone: true },
     {requestPayerName: true, requestPayerEmail: false, requestPayerPhone: true },
   ];
 
@@ -219,22 +216,32 @@ add_task(async function test_selective_f
     setPaymentOptions(requestStore, payerFields);
     await asyncElementRendered();
 
     let closedRichOption = elPicker.dropdown.querySelector(".rich-select-selected-option");
     let elName = closedRichOption.querySelector(".name");
     let elEmail = closedRichOption.querySelector(".email");
     let elPhone = closedRichOption.querySelector(".tel");
 
-    is(isVisible(elName), payerFields.requestPayerName,
+    is(!!elName && isVisible(elName), payerFields.requestPayerName,
        "name field is correctly toggled");
-    is(isVisible(elEmail), payerFields.requestPayerEmail,
+    is(!!elEmail && isVisible(elEmail), payerFields.requestPayerEmail,
        "email field is correctly toggled");
-    is(isVisible(elPhone), payerFields.requestPayerPhone,
+    is(!!elPhone && isVisible(elPhone), payerFields.requestPayerPhone,
        "tel field is correctly toggled");
+
+    let numPayerFieldsRequested = [...Object.values(payerFields)].filter(val => val).length;
+    is(elPicker.getAttribute("break-after-nth-field"), numPayerFieldsRequested == 3 ? "1" : null,
+       "Check @break-after-nth-field");
+    if (numPayerFieldsRequested == 3) {
+      is(closedRichOption.breakAfterNthField, "1",
+         "Make sure @break-after-nth-field was propagated to <address-option>");
+    } else {
+      is(closedRichOption.breakAfterNthField, null, "Make sure @break-after-nth-field was cleared");
+    }
   }
 });
 
 add_task(async function test_filtered_options() {
   await setup();
   let requestStore = elPicker.requestStore;
   setPaymentOptions(requestStore, {
     requestPayerName: true,
--- a/browser/components/payments/test/mochitest/test_payment_dialog.html
+++ b/browser/components/payments/test/mochitest/test_payment_dialog.html
@@ -8,16 +8,17 @@ Test the payment-dialog custom element
   <title>Test the payment-dialog element</title>
   <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/AddTask.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
   <script src="sinon-2.3.2.js"></script>
   <script src="payments_common.js"></script>
   <script src="../../res/vendor/custom-elements.min.js"></script>
   <script src="../../res/unprivileged-fallbacks.js"></script>
+  <script src="autofillEditForms.js"></script>
 
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
   <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
   <link rel="stylesheet" type="text/css" href="../../res/containers/rich-picker.css"/>
 </head>
 <body>
   <p id="display" style="height: 100vh; margin: 0;">
     <iframe id="templateFrame" src="../../res/paymentRequest.xhtml" width="0" height="0"
--- a/browser/components/payments/test/mochitest/test_payment_dialog_required_top_level_items.html
+++ b/browser/components/payments/test/mochitest/test_payment_dialog_required_top_level_items.html
@@ -7,16 +7,17 @@ Test the payment-dialog custom element
   <meta charset="utf-8">
   <title>Test the payment-dialog element</title>
   <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/AddTask.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
   <script src="payments_common.js"></script>
   <script src="../../res/vendor/custom-elements.min.js"></script>
   <script src="../../res/unprivileged-fallbacks.js"></script>
+  <script src="autofillEditForms.js"></script>
 
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
   <link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
   <link rel="stylesheet" type="text/css" href="../../res/containers/rich-picker.css"/>
 </head>
 <body>
   <p id="display" style="height: 100vh; margin: 0;">
     <iframe id="templateFrame" src="../../res/paymentRequest.xhtml" width="0" height="0"
--- a/browser/components/payments/test/mochitest/test_rich_select.html
+++ b/browser/components/payments/test/mochitest/test_rich_select.html
@@ -6,16 +6,18 @@ Test the rich-select component
 <head>
   <meta charset="utf-8">
   <title>Test the rich-select component</title>
   <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/AddTask.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
   <script src="payments_common.js"></script>
   <script src="../../res/vendor/custom-elements.min.js"></script>
+  <script src="../../res/unprivileged-fallbacks.js"></script>
+  <script src="autofillEditForms.js"></script>
 
   <link rel="stylesheet" type="text/css" href="../../res/components/rich-select.css"/>
   <link rel="stylesheet" type="text/css" href="../../res/components/address-option.css"/>
   <link rel="stylesheet" type="text/css" href="../../res/components/basic-card-option.css"/>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
 </head>
 <body>
   <p id="display">
@@ -56,16 +58,17 @@ let addresses = {
   },
 };
 
 let select1 = new RichSelect();
 for (let address of Object.values(addresses)) {
   let option = document.createElement("option");
   option.textContent = address.name + " " + address["street-address"];
   option.setAttribute("value", address.guid);
+  option.dataset.fieldSeparator = ", ";
   for (let field of Object.keys(address)) {
     option.setAttribute(field, address[field]);
   }
   select1.popupBox.appendChild(option);
 }
 select1.setAttribute("option-type", "address-option");
 select1.value = "";
 document.getElementById("display").appendChild(select1);
--- a/browser/extensions/formautofill/content/autofillEditForms.js
+++ b/browser/extensions/formautofill/content/autofillEditForms.js
@@ -155,20 +155,20 @@ class EditAddress extends EditAutofillFo
     super.loadRecord(record);
     this.formatForm(record.country);
   }
 
   /**
    * `mailing-address` is a special attribute token to indicate mailing fields + country.
    *
    * @param {object[]} mailingFieldsOrder - `fieldsOrder` from `getFormFormat`
+   * @param {string} addressFields - white-space-separated string of requested address fields to show
    * @returns {object[]} in the same structure as `mailingFieldsOrder` but including non-mail fields
    */
-  computeVisibleFields(mailingFieldsOrder) {
-    let addressFields = this._elements.form.dataset.addressFields;
+  static computeVisibleFields(mailingFieldsOrder, addressFields) {
     if (addressFields) {
       let requestedFieldClasses = addressFields.trim().split(/\s+/);
       let fieldClasses = [];
       if (requestedFieldClasses.includes("mailing-address")) {
         fieldClasses = fieldClasses.concat(mailingFieldsOrder);
         // `country` isn't part of the `mailingFieldsOrder` so add it when filling a mailing-address
         requestedFieldClasses.splice(requestedFieldClasses.indexOf("mailing-address"), 1,
                                      "country");
@@ -207,17 +207,18 @@ class EditAddress extends EditAutofillFo
     const {
       addressLevel1Label,
       postalCodeLabel,
       fieldsOrder: mailingFieldsOrder,
       postalCodePattern,
     } = this.getFormFormat(country);
     this._elements.addressLevel1Label.dataset.localization = addressLevel1Label;
     this._elements.postalCodeLabel.dataset.localization = postalCodeLabel;
-    let fieldClasses = this.computeVisibleFields(mailingFieldsOrder);
+    let addressFields = this._elements.form.dataset.addressFields;
+    let fieldClasses = EditAddress.computeVisibleFields(mailingFieldsOrder, addressFields);
     this.arrangeFields(fieldClasses);
     this.updatePostalCodeValidation(postalCodePattern);
   }
 
   /**
    * Update address field visibility and order based on libaddressinput data.
    *
    * @param {object[]} fieldsOrder array of objects with `fieldId` and optional `newLine` properties