Bug 1429195 - Implement and use a <payment-method-picker> custom element. r=jaws draft
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Wed, 07 Feb 2018 16:11:11 -0800
changeset 752748 6b652b070213f3c2a8315f3fe81bf7a2b52418a0
parent 752747 44f0f906b3feb0ce6ebfe299f58fe75ce981490d
child 752749 17a759cf1cd416c070d35ab5a219b0259c59c0dc
push id98362
push usermozilla@noorenberghe.ca
push dateThu, 08 Feb 2018 21:24:56 +0000
reviewersjaws
bugs1429195
milestone60.0a1
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>