Bug 1429195 - Implement and use a <payment-method-picker> custom element. r=jaws
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Wed, 07 Feb 2018 16:11:11 -0800
changeset 403022 5c40672bc6de26ae79abec4eee3a166887c8034a
parent 403021 07bac14a767bcf9cd57d5a0a91d00cf83dc0cd1c
child 403023 61eeb80f413cab34f9f7783a21259cb86517dad8
push id59328
push usermozilla@noorenberghe.ca
push dateThu, 08 Feb 2018 21:38:17 +0000
treeherderautoland@61eeb80f413c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjaws
bugs1429195
milestone60.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 1429195 - Implement and use a <payment-method-picker> custom element. r=jaws MozReview-Commit-ID: 9Ag7debD4IB
toolkit/components/payments/res/components/rich-select.js
toolkit/components/payments/res/containers/payment-method-picker.js
toolkit/components/payments/res/mixins/PaymentStateSubscriberMixin.js
toolkit/components/payments/res/paymentRequest.xhtml
toolkit/components/payments/test/mochitest/mochitest.ini
toolkit/components/payments/test/mochitest/payments_common.js
toolkit/components/payments/test/mochitest/test_payment_method_picker.html
--- a/toolkit/components/payments/res/components/rich-select.js
+++ b/toolkit/components/payments/res/components/rich-select.js
@@ -60,17 +60,16 @@ class RichSelect extends ObservedPropert
 
     this.render();
   }
 
   get selectedOption() {
     return this.popupBox.querySelector(":scope > [selected]");
   }
 
-
   /**
    * This is the only supported method of changing the selected option. Do not
    * manipulate the `selected` property or attribute on options directly.
    * @param {HTMLOptionElement} option
    */
   set selectedOption(option) {
     for (let child of this.popupBox.children) {
       child.selected = child == option;
@@ -105,23 +104,26 @@ class RichSelect extends ObservedPropert
       this.open = false;
     }
   }
 
   onClick(event) {
     if (event.button != 0) {
       return;
     }
+    // Cache the state of .open since the payment-method-picker change handler
+    // may cause onBlur to change .open to false and cause !this.open to change.
+    let isOpen = this.open;
 
     let option = event.target.closest(".rich-option");
-    if (this.open && option && !option.matches(".rich-select-selected-clone") && !option.selected) {
+    if (isOpen && option && !option.matches(".rich-select-selected-clone") && !option.selected) {
       this.selectedOption = option;
       this._dispatchChangeEvent();
     }
-    this.open = !this.open;
+    this.open = !isOpen;
   }
 
   onKeyDown(event) {
     if (event.key == " ") {
       this.open = !this.open;
     } else if (event.key == "ArrowDown") {
       let selectedOption = this.selectedOption;
       let next = selectedOption.nextElementSibling;
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/res/containers/payment-method-picker.js
@@ -0,0 +1,110 @@
+/* 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/. */
+
+/* global PaymentStateSubscriberMixin */
+
+"use strict";
+
+/**
+ * <payment-method-picker></payment-method-picker>
+ * Container around <rich-select> (eventually providing add/edit links) with
+ * <basic-card-option> listening to savedBasicCards.
+ */
+
+class PaymentMethodPicker extends PaymentStateSubscriberMixin(HTMLElement) {
+  constructor() {
+    super();
+    this.dropdown = document.createElement("rich-select");
+    this.dropdown.addEventListener("change", this);
+    this.spacerText = document.createTextNode(" ");
+    this.securityCodeInput = document.createElement("input");
+    this.securityCodeInput.autocomplete = "off";
+    this.securityCodeInput.size = 3;
+    this.securityCodeInput.placeholder = "CVC";
+    this.securityCodeInput.addEventListener("change", this);
+  }
+
+  connectedCallback() {
+    this.appendChild(this.dropdown);
+    this.appendChild(this.spacerText);
+    this.appendChild(this.securityCodeInput);
+    super.connectedCallback();
+  }
+
+  render(state) {
+    let {savedBasicCards} = state;
+    let desiredOptions = [];
+    for (let [guid, basicCard] of Object.entries(savedBasicCards)) {
+      let optionEl = this.dropdown.getOptionByValue(guid);
+      if (!optionEl) {
+        optionEl = document.createElement("basic-card-option");
+        optionEl.value = guid;
+      }
+      for (let [key, val] of Object.entries(basicCard)) {
+        optionEl.setAttribute(key, val);
+      }
+      desiredOptions.push(optionEl);
+    }
+    let el = null;
+    while ((el = this.dropdown.popupBox.querySelector(":scope > basic-card-option"))) {
+      el.remove();
+    }
+    for (let option of desiredOptions) {
+      this.dropdown.popupBox.appendChild(option);
+    }
+
+    // Update selectedness after the options are updated
+    let selectedPaymentCardGUID = state[this.selectedStateKey];
+    let optionWithGUID = this.dropdown.getOptionByValue(selectedPaymentCardGUID);
+    this.dropdown.selectedOption = optionWithGUID;
+
+    if (selectedPaymentCardGUID && !optionWithGUID) {
+      throw new Error(`${this.selectedStateKey} option ${selectedPaymentCardGUID}` +
+                      `does not exist in options`);
+    }
+  }
+
+  get selectedStateKey() {
+    return this.getAttribute("selected-state-key");
+  }
+
+  handleEvent(event) {
+    switch (event.type) {
+      case "change": {
+        this.onChange(event);
+        break;
+      }
+    }
+  }
+
+  onChange({target}) {
+    let selectedKey = this.selectedStateKey;
+    let stateChange = {};
+
+    if (!selectedKey) {
+      return;
+    }
+
+    switch (target) {
+      case this.dropdown: {
+        stateChange[selectedKey] = target.selectedOption && target.selectedOption.guid;
+        // Select the security code text since the user is likely to edit it next.
+        // We don't want to do this if the user simply blurs the dropdown.
+        this.securityCodeInput.select();
+        break;
+      }
+      case this.securityCodeInput: {
+        stateChange[selectedKey + "SecurityCode"] = this.securityCodeInput.value;
+        break;
+      }
+      default: {
+        return;
+      }
+    }
+
+    this.requestStore.setState(stateChange);
+  }
+}
+
+customElements.define("payment-method-picker", PaymentMethodPicker);
--- a/toolkit/components/payments/res/mixins/PaymentStateSubscriberMixin.js
+++ b/toolkit/components/payments/res/mixins/PaymentStateSubscriberMixin.js
@@ -32,16 +32,17 @@ let requestStore = new PaymentsStore({
       requestPayerName: false,
       requestPayerEmail: false,
       requestPayerPhone: false,
       requestShipping: false,
       shippingType: "shipping",
     },
   },
   selectedPaymentCard: null,
+  selectedPaymentCardSecurityCode: null,
   selectedShippingAddress: null,
   savedAddresses: {},
   savedBasicCards: {},
 });
 
 
 /* exported PaymentStateSubscriberMixin */
 
--- a/toolkit/components/payments/res/paymentRequest.xhtml
+++ b/toolkit/components/payments/res/paymentRequest.xhtml
@@ -5,16 +5,17 @@
 <!DOCTYPE html>
 <html xmlns="http://www.w3.org/1999/xhtml">
 <head>
   <meta http-equiv="Content-Security-Policy" content="default-src 'self'"/>
   <title></title>
   <link rel="stylesheet" href="paymentRequest.css"/>
   <link rel="stylesheet" href="components/rich-select.css"/>
   <link rel="stylesheet" href="components/address-option.css"/>
+  <link rel="stylesheet" href="components/basic-card-option.css"/>
   <link rel="stylesheet" href="components/payment-details-item.css"/>
   <link rel="stylesheet" href="containers/order-details.css"/>
 
   <script src="vendor/custom-elements.min.js"></script>
 
   <script src="PaymentsStore.js"></script>
 
   <script src="mixins/ObservedPropertiesMixin.js"></script>
@@ -22,16 +23,18 @@
 
   <script src="components/currency-amount.js"></script>
   <script src="containers/order-details.js"></script>
   <script src="components/payment-details-item.js"></script>
   <script src="components/rich-select.js"></script>
   <script src="components/rich-option.js"></script>
   <script src="components/address-option.js"></script>
   <script src="containers/address-picker.js"></script>
+  <script src="components/basic-card-option.js"></script>
+  <script src="containers/payment-method-picker.js"></script>
   <script src="containers/payment-dialog.js"></script>
 
   <script src="paymentRequest.js"></script>
 
   <template id="payment-dialog-template">
     <header>
       <div id="total">
         <h2 class="label"></h2>
@@ -45,16 +48,19 @@
 
     <div id="main-container">
       <section id="payment-summary">
         <h1>Your Payment</h1>
 
         <section>
           <div><label>Shipping Address</label></div>
           <address-picker selected-state-key="selectedShippingAddress"></address-picker>
+
+          <div><label>Payment Method</label></div>
+          <payment-method-picker selected-state-key="selectedPaymentCard"></payment-method-picker>
         </section>
 
         <footer id="controls-container">
           <button id="cancel">Cancel</button>
           <button id="pay">Pay</button>
         </footer>
       </section>
       <section id="order-details-overlay" hidden="hidden">
--- a/toolkit/components/payments/test/mochitest/mochitest.ini
+++ b/toolkit/components/payments/test/mochitest/mochitest.ini
@@ -11,22 +11,24 @@ support-files =
    ../../res/components/basic-card-option.css
    ../../res/components/payment-details-item.js
    ../../res/components/rich-option.js
    ../../res/components/rich-select.css
    ../../res/components/rich-select.js
    ../../res/containers/address-picker.js
    ../../res/containers/order-details.js
    ../../res/containers/payment-dialog.js
+   ../../res/containers/payment-method-picker.js
    ../../res/mixins/ObservedPropertiesMixin.js
    ../../res/mixins/PaymentStateSubscriberMixin.js
    ../../res/vendor/custom-elements.min.js
    ../../res/vendor/custom-elements.min.js.map
    payments_common.js
 
 [test_address_picker.html]
 [test_currency_amount.html]
 [test_order_details.html]
 [test_payment_dialog.html]
 [test_payment_details_item.html]
+[test_payment_method_picker.html]
 [test_rich_select.html]
 [test_ObservedPropertiesMixin.html]
 [test_PaymentStateSubscriberMixin.html]
--- a/toolkit/components/payments/test/mochitest/payments_common.js
+++ b/toolkit/components/payments/test/mochitest/payments_common.js
@@ -1,12 +1,23 @@
 "use strict";
 
-/* exported asyncElementRendered */
+/* exported asyncElementRendered, promiseStateChange */
 
 /**
  * A helper to await on while waiting for an asynchronous rendering of a Custom
  * Element.
  * @returns {Promise}
  */
 function asyncElementRendered() {
   return Promise.resolve();
 }
+
+function promiseStateChange(store) {
+  return new Promise(resolve => {
+    store.subscribe({
+      stateChangeCallback(state) {
+        store.unsubscribe(this);
+        resolve(state);
+      },
+    });
+  });
+}
copy from toolkit/components/payments/test/mochitest/test_address_picker.html
copy to toolkit/components/payments/test/mochitest/test_payment_method_picker.html
--- a/toolkit/components/payments/test/mochitest/test_address_picker.html
+++ b/toolkit/components/payments/test/mochitest/test_payment_method_picker.html
@@ -1,159 +1,170 @@
 <!DOCTYPE HTML>
 <html>
 <!--
-Test the address-picker component
+Test the payment-method-picker component
 -->
 <head>
   <meta charset="utf-8">
-  <title>Test the address-picker component</title>
+  <title>Test the payment-method-picker component</title>
   <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
   <script type="application/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
   <script src="payments_common.js"></script>
   <script src="custom-elements.min.js"></script>
   <script src="PaymentsStore.js"></script>
   <script src="ObservedPropertiesMixin.js"></script>
   <script src="PaymentStateSubscriberMixin.js"></script>
   <script src="rich-select.js"></script>
-  <script src="address-picker.js"></script>
+  <script src="payment-method-picker.js"></script>
   <script src="rich-option.js"></script>
-  <script src="address-option.js"></script>
+  <script src="basic-card-option.js"></script>
   <link rel="stylesheet" type="text/css" href="rich-select.css"/>
-  <link rel="stylesheet" type="text/css" href="address-option.css"/>
+  <link rel="stylesheet" type="text/css" href="basic-card-option.css"/>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
 </head>
 <body>
   <p id="display">
-    <address-picker id="picker1"
-                    selected-state-key="selectedShippingAddress"></address-picker>
+    <payment-method-picker id="picker1"
+                           selected-state-key="selectedPaymentCard"></payment-method-picker>
   </p>
 <div id="content" style="display: none">
 
 </div>
 <pre id="test">
 </pre>
 <script type="application/javascript">
-/** Test the address-picker component **/
+/** Test the payment-method-picker component **/
 
 /* import-globals-from payments_common.js */
-/* import-globals-from ../../res/components/address-option.js */
+/* import-globals-from ../../res/components/basic-card-option.js */
 
 let picker1 = document.getElementById("picker1");
 
 add_task(async function test_empty() {
   ok(picker1, "Check picker1 exists");
-  let {savedAddresses} = picker1.requestStore.getState();
-  is(Object.keys(savedAddresses).length, 0, "Check empty initial state");
+  let {savedBasicCards} = picker1.requestStore.getState();
+  is(Object.keys(savedBasicCards).length, 0, "Check empty initial state");
   is(picker1.dropdown.popupBox.children.length, 0, "Check dropdown is empty");
 });
 
 add_task(async function test_initialSet() {
   picker1.requestStore.setState({
-    savedAddresses: {
+    savedBasicCards: {
       "48bnds6854t": {
-        "address-level1": "MI",
-        "address-level2": "Some City",
-        "country": "US",
+        "cc-exp": "2017-02",
+        "cc-exp-month": 2,
+        "cc-exp-year": 2017,
+        "cc-name": "John Doe",
+        "cc-number": "************9999",
         "guid": "48bnds6854t",
-        "name": "Mr. Foo",
-        "postal-code": "90210",
-        "street-address": "123 Sesame Street,\nApt 40",
-        "tel": "+1 519 555-5555",
       },
       "68gjdh354j": {
-        "address-level1": "CA",
-        "address-level2": "Mountain View",
-        "country": "US",
+        "cc-exp": "2017-08",
+        "cc-exp-month": 8,
+        "cc-exp-year": 2017,
+        "cc-name": "J Smith",
+        "cc-number": "***********1234",
         "guid": "68gjdh354j",
-        "name": "Mrs. Bar",
-        "postal-code": "94041",
-        "street-address": "P.O. Box 123",
-        "tel": "+1 650 555-5555",
+      },
+    },
+  });
+  await asyncElementRendered();
+  let options = picker1.dropdown.popupBox.children;
+  is(options.length, 2, "Check dropdown has both cards");
+  ok(options[0].textContent.includes("John Doe"), "Check first card");
+  ok(options[1].textContent.includes("J Smith"), "Check second card");
+});
+
+add_task(async function test_update() {
+  picker1.requestStore.setState({
+    savedBasicCards: {
+      "48bnds6854t": {
+        // Same GUID, different values to trigger an update
+        "cc-exp": "2017-09",
+        "cc-exp-month": 9,
+        "cc-exp-year": 2017,
+        "cc-name": "John Edit Doe",
+        "cc-number": "************9876",
+        "guid": "48bnds6854t",
+      },
+      "68gjdh354j": {
+        "cc-exp": "2017-08",
+        "cc-exp-month": 8,
+        "cc-exp-year": 2017,
+        "cc-name": "J Smith",
+        "cc-number": "***********1234",
+        "guid": "68gjdh354j",
       },
     },
   });
   await asyncElementRendered();
   let options = picker1.dropdown.popupBox.children;
-  is(options.length, 2, "Check dropdown has both addresses");
-  ok(options[0].textContent.includes("123 Sesame Street"), "Check first address");
-  ok(options[1].textContent.includes("P.O. Box 123"), "Check second address");
+  is(options.length, 2, "Check dropdown still has both cards");
+  ok(options[0].textContent.includes("John Edit Doe"), "Check updated first cc-name");
+  ok(options[0].textContent.includes("9876"), "Check updated first cc-number");
+  ok(options[0].textContent.includes("09"), "Check updated first exp-month");
+
+  ok(options[1].textContent.includes("J Smith"), "Check second card is the same");
 });
 
-add_task(async function test_update() {
+add_task(async function test_change_selected_card() {
+  let options = picker1.dropdown.popupBox.children;
+  let selectedOption = picker1.dropdown.selectedOption;
+  is(selectedOption, null, "Should default to no selected option");
+  let {
+    selectedPaymentCard,
+    selectedPaymentCardSecurityCode,
+  } = picker1.requestStore.getState();
+  is(selectedPaymentCard, null, "store should have no option selected");
+  is(selectedPaymentCardSecurityCode, null, "store should have no security code");
+
+  await SimpleTest.promiseFocus();
+  let codeFocusPromise = new Promise(resolve => {
+    picker1.securityCodeInput.addEventListener("focus", resolve, {once: true});
+  });
+  picker1.dropdown.click();
+  options[1].click();
+  await asyncElementRendered();
+  await codeFocusPromise;
+  ok(true, "Focused the security code field");
+  ok(!picker1.open, "Picker should be closed");
+
+  selectedOption = picker1.dropdown.selectedOption;
+  is(selectedOption, options[1], "Selected option should now be the second option");
+  selectedPaymentCard = picker1.requestStore.getState().selectedPaymentCard;
+  is(selectedPaymentCard, selectedOption.guid, "store should have second option selected");
+  selectedPaymentCardSecurityCode = picker1.requestStore.getState().selectedPaymentCardSecurityCode;
+  is(selectedPaymentCardSecurityCode, null, "store should have empty security code");
+
+  let stateChangePromise = promiseStateChange(picker1.requestStore);
+
+  // Type in the security code field
+  sendString("836");
+  sendKey("Tab");
+  let state = await stateChangePromise;
+  ok(state.selectedPaymentCardSecurityCode, "836", "Check security code in state");
+});
+
+add_task(async function test_delete() {
   picker1.requestStore.setState({
-    savedAddresses: {
-      "48bnds6854t": {
-        // Same GUID, different values to trigger an update
-        "address-level1": "MI-edit",
-        "address-level2": "Some City-edit",
-        "country": "CA",
-        "guid": "48bnds6854t",
-        "name": "Mr. Foo-edit",
-        "postal-code": "90210-1234",
-        "street-address": "new-edit",
-        "tel": "+1 650 555-5555",
-      },
+    savedBasicCards: {
+      // 48bnds6854t was deleted
       "68gjdh354j": {
-        "address-level1": "CA",
-        "address-level2": "Mountain View",
-        "country": "US",
+        "cc-exp": "2017-08",
+        "cc-exp-month": 8,
+        "cc-exp-year": 2017,
+        "cc-name": "J Smith",
+        "cc-number": "***********1234",
         "guid": "68gjdh354j",
-        "name": "Mrs. Bar",
-        "postal-code": "94041",
-        "street-address": "P.O. Box 123",
-        "tel": "+1 650 555-5555",
       },
     },
   });
   await asyncElementRendered();
   let options = picker1.dropdown.popupBox.children;
-  is(options.length, 2, "Check dropdown still has both addresses");
-  ok(options[0].textContent.includes("MI-edit"), "Check updated first address-level1");
-  ok(options[0].textContent.includes("Some City-edit"), "Check updated first address-level2");
-  ok(options[0].textContent.includes("new-edit"), "Check updated first address");
-
-  ok(options[1].textContent.includes("P.O. Box 123"), "Check second address is the same");
-});
-
-add_task(async function test_change_selected_address() {
-  let options = picker1.dropdown.popupBox.children;
-  let selectedOption = picker1.dropdown.selectedOption;
-  is(selectedOption, null, "Should default to no selected option");
-  let {selectedShippingAddress} = picker1.requestStore.getState();
-  is(selectedShippingAddress, null, "store should have no option selected");
-
-  picker1.dropdown.click();
-  options[1].click();
-  await asyncElementRendered();
-
-  selectedOption = picker1.dropdown.selectedOption;
-  is(selectedOption, options[1], "Selected option should now be the second option");
-  selectedShippingAddress = picker1.requestStore.getState().selectedShippingAddress;
-  is(selectedShippingAddress, selectedOption.guid, "store should have second option selected");
-});
-
-add_task(async function test_delete() {
-  picker1.requestStore.setState({
-    savedAddresses: {
-      // 48bnds6854t was deleted
-      "68gjdh354j": {
-        "address-level1": "CA",
-        "address-level2": "Mountain View",
-        "country": "US",
-        "guid": "68gjdh354j",
-        "name": "Mrs. Bar",
-        "postal-code": "94041",
-        "street-address": "P.O. Box 123",
-        "tel": "+1 650 555-5555",
-      },
-    },
-  });
-  await asyncElementRendered();
-  let options = picker1.dropdown.popupBox.children;
-  is(options.length, 1, "Check dropdown has one remaining address");
-  ok(options[0].textContent.includes("P.O. Box 123"), "Check remaining address");
+  is(options.length, 1, "Check dropdown has one remaining card");
+  ok(options[0].textContent.includes("J Smith"), "Check remaining card");
 });
 </script>
 
 </body>
 </html>