Bug 1429205 - Implement the Payment Request processing screen. r=jaws
☠☠ backed out by c6a22782eeb4 ☠ ☠
authorMatthew Noorenberghe <mozilla@noorenberghe.ca>
Thu, 22 Feb 2018 15:19:52 -0800
changeset 459941 3f8c4cb7982f3b8ef0915b4e274c4135ffcac6b8
parent 459940 ced4c7041d8e6edf21709e736e867fa30bc07e96
child 459942 3ab43ccebcdebfe946c451e82b4798db019d474e
push id1683
push usersfraser@mozilla.com
push dateThu, 26 Apr 2018 16:43:40 +0000
treeherdermozilla-release@5af6cb21869d [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjaws
bugs1429205
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 1429205 - Implement the Payment Request processing screen. r=jaws MozReview-Commit-ID: 8MZAtjxRHEa
toolkit/components/payments/content/paymentDialogWrapper.js
toolkit/components/payments/res/containers/payment-dialog.js
toolkit/components/payments/res/debugging.html
toolkit/components/payments/res/debugging.js
toolkit/components/payments/res/mixins/PaymentStateSubscriberMixin.js
toolkit/components/payments/res/paymentRequest.css
toolkit/components/payments/res/paymentRequest.js
toolkit/components/payments/res/paymentRequest.xhtml
toolkit/components/payments/test/mochitest/mochitest.ini
toolkit/components/payments/test/mochitest/test_payment_dialog.html
--- a/toolkit/components/payments/content/paymentDialogWrapper.js
+++ b/toolkit/components/payments/content/paymentDialogWrapper.js
@@ -392,16 +392,17 @@ var paymentDialogWrapper = {
       acceptStatus: Ci.nsIPaymentActionResponse.PAYMENT_ACCEPTED,
       payerName,
       payerEmail,
       payerPhone,
       methodName,
       methodData,
     });
     paymentSrv.respondPayment(showResponse);
+    this.sendMessageToContent("responseSent");
   },
 
   async onChangeShippingAddress({shippingAddressGUID}) {
     let address = await this._convertProfileAddressToPaymentAddress(shippingAddressGUID);
     paymentSrv.changeShippingAddress(this.request.requestId, address);
   },
 
   onChangeShippingOption({optionID}) {
--- a/toolkit/components/payments/res/containers/payment-dialog.js
+++ b/toolkit/components/payments/res/containers/payment-dialog.js
@@ -28,16 +28,18 @@ class PaymentDialog extends PaymentState
     this._payButton.addEventListener("click", this);
 
     this._viewAllButton = contents.querySelector("#view-all");
     this._viewAllButton.addEventListener("click", this);
 
     this._orderDetailsOverlay = contents.querySelector("#order-details-overlay");
     this._shippingRequestedEls = contents.querySelectorAll(".shippingRequested");
 
+    this._disabledOverlay = contents.getElementById("disabled-overlay");
+
     this.appendChild(contents);
 
     super.connectedCallback();
   }
 
   disconnectedCallback() {
     this._cancelButton.removeEventListener("click", this.cancelRequest);
     this._payButton.removeEventListener("click", this.pay);
@@ -141,16 +143,29 @@ class PaymentDialog extends PaymentState
       }
       this._cachedState.selectedShippingOption = selectedShippingOption;
       this.requestStore.setState({
         selectedShippingOption,
       });
     }
   }
 
+  _renderPayButton(state) {
+    this._payButton.disabled = state.changesPrevented;
+    switch (state.completionState) {
+      case "initial":
+      case "processing":
+        break;
+      default:
+        throw new Error("Invalid completionState");
+    }
+
+    this._payButton.textContent = this._payButton.dataset[state.completionState + "Label"];
+  }
+
   stateChangeCallback(state) {
     super.stateChangeCallback(state);
 
     if (state.selectedShippingAddress != this._cachedState.selectedShippingAddress) {
       this.changeShippingAddress(state.selectedShippingAddress);
     }
 
     if (state.selectedShippingOption != this._cachedState.selectedShippingOption) {
@@ -169,12 +184,26 @@ class PaymentDialog extends PaymentState
     let totalAmountEl = this.querySelector("#total > currency-amount");
     totalAmountEl.value = totalItem.amount.value;
     totalAmountEl.currency = totalItem.amount.currency;
 
     this._orderDetailsOverlay.hidden = !state.orderDetailsShowing;
     for (let element of this._shippingRequestedEls) {
       element.hidden = !request.paymentOptions.requestShipping;
     }
+
+    this._renderPayButton(state);
+
+    let {
+      changesPrevented,
+      completionState,
+    } = state;
+    if (changesPrevented) {
+      this.setAttribute("changes-prevented", "");
+    } else {
+      this.removeAttribute("changes-prevented");
+    }
+    this.setAttribute("completion-state", completionState);
+    this._disabledOverlay.hidden = !changesPrevented;
   }
 }
 
 customElements.define("payment-dialog", PaymentDialog);
--- a/toolkit/components/payments/res/debugging.html
+++ b/toolkit/components/payments/res/debugging.html
@@ -11,19 +11,25 @@
   </head>
   <body>
     <div>
       <button id="refresh">Refresh</button>
       <button id="rerender">Re-render</button>
       <button id="logState">Log state</button>
       <button id="debugFrame" hidden>Debug frame</button>
       <h1>Requests</h1>
-      <button id="setRequest1">Set Request 1</button>
-      <button id="setRequest2">Set Request 2</button>
+      <button id="setRequest1">Request 1</button>
+      <button id="setRequest2">Request 2</button>
+      <button id="setRequestContactNoShipping">Contact &amp; No Shipping</button>
       <h1>Addresses</h1>
       <button id="setAddresses1">Set Addreses 1</button>
       <button id="delete1Address">Delete 1 Address</button>
       <h1>Payment Methods</h1>
       <button id="setBasicCards1">Set Basic Cards 1</button>
       <button id="delete1Card">Delete 1 Card</button>
+      <h1>States</h1>
+      <button id="setChangesPrevented">Prevent changes</button>
+      <button id="setChangesAllowed">Allow changes</button>
+      <button id="setStateDefault">Default</button>
+      <button id="setStateProcessing">Processing</button>
     </div>
   </body>
 </html>
--- a/toolkit/components/payments/res/debugging.js
+++ b/toolkit/components/payments/res/debugging.js
@@ -46,29 +46,29 @@ let REQUEST_1 = {
     ],
     modifiers: null,
     error: "",
   },
   paymentOptions: {
     requestPayerName: false,
     requestPayerEmail: false,
     requestPayerPhone: false,
-    requestShipping: false,
+    requestShipping: true,
     shippingType: "shipping",
   },
 };
 
 let REQUEST_2 = {
   tabId: 9,
   topLevelPrincipal: {URI: {displayHost: "example.com"}},
   requestId: "3797081f-a96b-c34b-a58b-1083c6e66e25",
   paymentMethods: [],
   paymentDetails: {
     id: "",
-    totalItem: {label: "Demo total", amount: {currency: "CAD", value: "25.75"}, pending: false},
+    totalItem: {label: "", amount: {currency: "CAD", value: "25.75"}, pending: false},
     displayItems: [
       {
         label: "Triangle",
         amount: {
           currency: "CAD",
           value: "3",
         },
       },
@@ -110,16 +110,38 @@ let REQUEST_2 = {
     ],
     modifiers: null,
     error: "",
   },
   paymentOptions: {
     requestPayerName: false,
     requestPayerEmail: false,
     requestPayerPhone: false,
+    requestShipping: true,
+    shippingType: "shipping",
+  },
+};
+
+let REQUEST_CONTACT_NO_SHIPPING = {
+  tabId: 10,
+  topLevelPrincipal: {URI: {displayHost: "example.org"}},
+  requestId: "8288347a-ccec-4190-b4b1-673dbc709738",
+  paymentMethods: [],
+  paymentDetails: {
+    id: "",
+    totalItem: {label: "", amount: {currency: "EUR", value: "1234.56"}, pending: false},
+    displayItems: [],
+    shippingOptions: [],
+    modifiers: null,
+    error: "",
+  },
+  paymentOptions: {
+    requestPayerName: true,
+    requestPayerEmail: true,
+    requestPayerPhone: true,
     requestShipping: false,
     shippingType: "shipping",
   },
 };
 
 let ADDRESSES_1 = {
   "48bnds6854t": {
     "address-level1": "MI",
@@ -221,23 +243,51 @@ let buttonActions = {
   setAddresses1() {
     paymentDialog.setStateFromParent({savedAddresses: ADDRESSES_1});
   },
 
   setBasicCards1() {
     paymentDialog.setStateFromParent({savedBasicCards: BASIC_CARDS_1});
   },
 
+  setChangesAllowed() {
+    requestStore.setState({
+      changesPrevented: false,
+    });
+  },
+
+  setChangesPrevented() {
+    requestStore.setState({
+      changesPrevented: true,
+    });
+  },
+
   setRequest1() {
     requestStore.setState({request: REQUEST_1});
   },
 
   setRequest2() {
     requestStore.setState({request: REQUEST_2});
   },
+
+  setRequestContactNoShipping() {
+    requestStore.setState({request: REQUEST_CONTACT_NO_SHIPPING});
+  },
+
+  setStateDefault() {
+    requestStore.setState({
+      completionState: "initial",
+    });
+  },
+
+  setStateProcessing() {
+    requestStore.setState({
+      completionState: "processing",
+    });
+  },
 };
 
 window.addEventListener("click", function onButtonClick(evt) {
   let id = evt.target.id;
   if (!id || typeof(buttonActions[id]) != "function") {
     return;
   }
 
--- a/toolkit/components/payments/res/mixins/PaymentStateSubscriberMixin.js
+++ b/toolkit/components/payments/res/mixins/PaymentStateSubscriberMixin.js
@@ -9,16 +9,18 @@
 /**
  * A mixin for a custom element to observe store changes to information about a payment request.
  */
 
 /**
  * State of the payment request dialog.
  */
 let requestStore = new PaymentsStore({
+  changesPrevented: false,
+  completionState: "initial",
   orderDetailsShowing: false,
   request: {
     tabId: null,
     topLevelPrincipal: {URI: {displayHost: null}},
     requestId: null,
     paymentMethods: [],
     paymentDetails: {
       id: null,
--- a/toolkit/components/payments/res/paymentRequest.css
+++ b/toolkit/components/payments/res/paymentRequest.css
@@ -10,16 +10,19 @@ html {
 body {
   height: 100%;
   margin: 0;
   overflow: hidden;
 }
 
 #debugging-console {
   float: right;
+  /* Float above the other overlays */
+  position: relative;
+  z-index: 99;
 }
 
 payment-dialog {
   display: grid;
   grid-template-rows: fit-content(10%) auto;
   height: 100%;
   margin: 0 10%;
 }
@@ -53,23 +56,42 @@ payment-dialog > header {
 
 payment-dialog > footer {
   align-items: baseline;
   display: flex;
 }
 
 #total {
   flex: 1 1 auto;
+  margin: 5px;
 }
 #view-all {
   flex: 0 1 auto;
 }
 
-#total {
-  border: 1px solid black;
-  margin: 5px;
-  text-align: center;
-}
-
 #total .label {
   font-size: 15px;
   font-weight: bold;
 }
+
+#pay {
+  background-color: #0060df;
+  color: white;
+  border: none;
+}
+
+payment-dialog[changes-prevented][completion-state="processing"] #pay {
+  /* Show the pay button above #disabled-overlay */
+  position: relative;
+  z-index: 1;
+}
+
+#disabled-overlay {
+  background: white;
+  opacity: 0.6;
+  width: 100%;
+  height: 100%;
+  position: absolute;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  left: 0;
+}
--- a/toolkit/components/payments/res/paymentRequest.js
+++ b/toolkit/components/payments/res/paymentRequest.js
@@ -64,16 +64,23 @@ var paymentRequest = {
     });
     document.dispatchEvent(event);
   },
 
   onChromeToContent({detail}) {
     let {messageType} = detail;
 
     switch (messageType) {
+      case "responseSent": {
+        document.querySelector("payment-dialog").requestStore.setState({
+          changesPrevented: true,
+          completionState: "processing",
+        });
+        break;
+      }
       case "showPaymentRequest": {
         this.onShowPaymentRequest(detail);
         break;
       }
       case "updateState": {
         document.querySelector("payment-dialog").setStateFromParent(detail);
         break;
       }
--- a/toolkit/components/payments/res/paymentRequest.xhtml
+++ b/toolkit/components/payments/res/paymentRequest.xhtml
@@ -3,18 +3,19 @@
    - 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/. -->
 <!DOCTYPE html [
   <!ENTITY viewAllItems               "View All Items">
   <!ENTITY paymentSummaryTitle        "Your Payment">
   <!ENTITY shippingAddressLabel       "Shipping Address">
   <!ENTITY shippingOptionsLabel       "Shipping Options">
   <!ENTITY paymentMethodsLabel        "Payment Method">
-  <!ENTITY cancelPaymentButtonLabel   "Cancel">
-  <!ENTITY approvePaymentButtonLabel  "Pay">
+  <!ENTITY cancelPaymentButton.label   "Cancel">
+  <!ENTITY approvePaymentButton.label  "Pay">
+  <!ENTITY processingPaymentButton.label "Processing">
   <!ENTITY orderDetailsLabel          "Order Details">
   <!ENTITY orderTotalLabel            "Total">
 ]>
 <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"/>
@@ -68,37 +69,43 @@
           <address-picker class="shippingRequested" selected-state-key="selectedShippingAddress"></address-picker>
           <div class="shippingRequested"><label>&shippingOptionsLabel;</label></div>
           <shipping-option-picker class="shippingRequested"></shipping-option-picker>
           <div><label>&paymentMethodsLabel;</label></div>
           <payment-method-picker selected-state-key="selectedPaymentCard"></payment-method-picker>
         </section>
 
         <footer id="controls-container">
-          <button id="cancel">&cancelPaymentButtonLabel;</button>
-          <button id="pay">&approvePaymentButtonLabel;</button>
+          <button id="cancel">&cancelPaymentButton.label;</button>
+          <button id="pay"
+                  data-initial-label="&approvePaymentButton.label;"
+                  data-processing-label="&processingPaymentButton.label;"></button>
         </footer>
       </section>
       <section id="order-details-overlay" hidden="hidden">
         <h1>&orderDetailsLabel;</h1>
         <order-details></order-details>
       </section>
     </div>
+
+    <div id="disabled-overlay" hidden="hidden">
+      <!-- overlay to prevent changes while waiting for a response from the merchant -->
+    </div>
   </template>
 
   <template id="order-details-template">
     <ul class="main-list"></ul>
     <ul class="footer-items-list"></ul>
 
     <div class="details-total">
       <h2 class="label">&orderTotalLabel;</h2>
       <currency-amount></currency-amount>
     </div>
   </template>
 </head>
 <body>
   <iframe id="debugging-console"
           hidden="hidden"
-          height="300"
+          height="400"
           src="debugging.html"></iframe>
   <payment-dialog></payment-dialog>
 </body>
 </html>
--- a/toolkit/components/payments/test/mochitest/mochitest.ini
+++ b/toolkit/components/payments/test/mochitest/mochitest.ini
@@ -1,13 +1,15 @@
 [DEFAULT]
 prefs =
    dom.webcomponents.customelements.enabled=false
 support-files =
    ../../../../../testing/modules/sinon-2.3.2.js
+   ../../res/paymentRequest.css
+   ../../res/paymentRequest.xhtml
    ../../res/PaymentsStore.js
    ../../res/components/currency-amount.js
    ../../res/components/address-option.js
    ../../res/components/address-option.css
    ../../res/components/basic-card-option.js
    ../../res/components/basic-card-option.css
    ../../res/components/payment-details-item.js
    ../../res/components/rich-option.js
--- a/toolkit/components/payments/test/mochitest/test_payment_dialog.html
+++ b/toolkit/components/payments/test/mochitest/test_payment_dialog.html
@@ -11,110 +11,150 @@ Test the payment-dialog custom element
   <script src="sinon-2.3.2.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="payment-dialog.js"></script>
   <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
-
-  <template id="payment-dialog-template">
-    <header>
-      <div id="total">
-        <h2 class="label"></h2>
-        <currency-amount></currency-amount>
-        <div id="host-name"></div>
-      </div>
-      <div id="top-buttons" >
-        <button id="view-all" class="closed">View All Items</button>
-      </div>
-    </header>
-
-    <div id="main-container">
-      <section id="payment-summary">
-        <h1>Your Payment</h1>
-
-        <footer id="controls-container">
-          <button id="cancel">Cancel</button>
-          <button id="pay">Pay</button>
-        </footer>
-      </section>
-      <section id="order-details-overlay" hidden="true">
-        <h1>Order Details</h1>
-      </section>
-    </div>
-  </template>
+  <link rel="stylesheet" type="text/css" href="paymentRequest.css"/>
 </head>
 <body>
   <p id="display">
-    <payment-dialog id="el1"></test-element>
+    <iframe id="templateFrame" src="paymentRequest.xhtml"></iframe>
   </p>
 <div id="content" style="display: none">
 
 </div>
 <pre id="test">
 </pre>
 <script type="application/javascript">
 /** Test the payment-dialog element **/
 
 /* global sinon */
 /* import-globals-from payments_common.js */
 /* import-globals-from ../../res/mixins/PaymentStateSubscriberMixin.js */
 
-let el1 = document.getElementById("el1");
-
-sinon.spy(el1, "render");
-sinon.spy(el1, "stateChangeCallback");
+let el1;
 
 /* test that:
   the view-all-items button exists
   that clicking it changes the state on the store
   that clicking it causes render to be called
 
   that order details element's hidden state matches the state on the store
 */
 
-function setup() {
+add_task(async function setup_once() {
+  let templateFrame = document.getElementById("templateFrame");
+  await SimpleTest.promiseFocus(templateFrame.contentWindow);
+
+  // 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);
+    document.getElementById("display").appendChild(imported);
+  }
+
+  el1 = document.createElement("payment-dialog");
+  document.getElementById("display").appendChild(el1);
+
+  sinon.spy(el1, "render");
+  sinon.spy(el1, "stateChangeCallback");
+});
+
+async function setup() {
+  await el1.requestStore.setState({
+    changesPrevented: false,
+    completionState: "initial",
+    orderDetailsShowing: false,
+  });
+
   el1.render.reset();
   el1.stateChangeCallback.reset();
-  let elDetails = el1._orderDetailsOverlay;
-  if (elDetails) {
-    elDetails.setAttribute("hidden", "true");
-  }
 }
 
 add_task(async function test_initialState() {
-  setup();
+  await setup();
   let initialState = el1.requestStore.getState();
   let elDetails = el1._orderDetailsOverlay;
 
   is(initialState.orderDetailsShowing, false, "orderDetailsShowing is initially false");
-  ok(elDetails.hasAttribute("hidden"));
+  ok(elDetails.hasAttribute("hidden"), "Check details are hidden");
 });
 
 add_task(async function test_viewAllButton() {
-  setup();
+  await setup();
 
   let elDetails = el1._orderDetailsOverlay;
   let button = el1._viewAllButton;
 
   button.click();
   await asyncElementRendered();
 
   ok(el1.stateChangeCallback.calledOnce, "stateChangeCallback called once");
   ok(el1.render.calledOnce, "render called once");
 
   let state = el1.requestStore.getState();
   is(state.orderDetailsShowing, true, "orderDetailsShowing becomes true");
-  ok(!elDetails.hasAttribute("hidden"));
+  ok(!elDetails.hasAttribute("hidden"), "Check details aren't hidden");
+});
+
+add_task(async function test_changesPrevented() {
+  await setup();
+  let state = el1.requestStore.getState();
+  is(state.changesPrevented, false, "changesPrevented is initially false");
+  let disabledOverlay = document.getElementById("disabled-overlay");
+  ok(disabledOverlay.hidden, "Overlay should initially be hidden");
+  await el1.requestStore.setState({changesPrevented: true});
+  await asyncElementRendered();
+  ok(!disabledOverlay.hidden, "Overlay should prevent changes");
+});
+
+add_task(async function test_completionState() {
+  await setup();
+  let state = el1.requestStore.getState();
+  is(state.completionState, "initial", "completionState is initially initial");
+  let payButton = document.getElementById("pay");
+  is(payButton.textContent, "Pay", "Check default label");
+  ok(!payButton.disabled, "Button is enabled");
+  await el1.requestStore.setState({completionState: "processing"});
+  await asyncElementRendered();
+  is(payButton.textContent, "Processing", "Check processing label");
+  ok(!payButton.disabled, "Button is still enabled");
+});
+
+add_task(async function test_completionStateChangesPrevented() {
+  await setup();
+  let state = el1.requestStore.getState();
+  is(state.completionState, "initial", "completionState is initially initial");
+  is(state.changesPrevented, false, "changesPrevented is initially false");
+  let payButton = document.getElementById("pay");
+  is(payButton.textContent, "Pay", "Check default label");
+  ok(!payButton.disabled, "Button is enabled");
+
+  await el1.requestStore.setState({
+    changesPrevented: true,
+    completionState: "processing",
+  });
+  await asyncElementRendered();
+  is(payButton.textContent, "Processing", "Check processing label");
+  ok(payButton.disabled, "Button is disabled");
+  let {
+    x,
+    y,
+    width,
+    height,
+  } = payButton.getBoundingClientRect();
+  let visibleElement = document.elementFromPoint(x + width / 2, y + height / 2);
+  ok(payButton === visibleElement, "Pay button is on top of the overlay");
 });
 
 add_task(async function test_disconnect() {
-  setup();
+  await setup();
 
   el1.remove();
   await el1.requestStore.setState({orderDetailsShowing: true});
   await asyncElementRendered();
   ok(el1.stateChangeCallback.notCalled, "stateChangeCallback not called");
   ok(el1.render.notCalled, "render not called");
 
   let elDetails = el1._orderDetailsOverlay;