Bug 1427936 - Add an order-details component and details-payment-items. r=mattn draft
authorSam Foster <sfoster@mozilla.com>
Fri, 12 Jan 2018 15:03:54 -0800
changeset 749882 ccff57b5e43199843a8e5983f012de1d885535c0
parent 749604 3804441e575c9f46fcb03894de3c780eeae7197f
push id97498
push userbmo:sfoster@mozilla.com
push dateThu, 01 Feb 2018 00:24:11 +0000
reviewersmattn
bugs1427936
milestone60.0a1
Bug 1427936 - Add an order-details component and details-payment-items. r=mattn * The order-details container arranges and populates the items list and the 2nd total line under the items. * The displayItems list is rendered into 2 lists, a "footer-items" list which displays tax and similar items just before the total, and a "main" list for everything else. * The list items are instances of a payment-details-item component * Initial layout of the line items and total with css grid * Tests for order-details component * Tests for payment-detail-item component MozReview-Commit-ID: 68r8SSgwHgq
toolkit/components/payments/res/components/payment-details-item.css
toolkit/components/payments/res/components/payment-details-item.js
toolkit/components/payments/res/containers/order-details.css
toolkit/components/payments/res/containers/order-details.js
toolkit/components/payments/res/debugging.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/test_order_details.html
toolkit/components/payments/test/mochitest/test_payment_details_item.html
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/res/components/payment-details-item.css
@@ -0,0 +1,8 @@
+payment-details-item {
+  margin: 1px 0;
+  min-height: 2em;
+}
+
+payment-details-item > currency-amount {
+  text-align: end;
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/res/components/payment-details-item.js
@@ -0,0 +1,51 @@
+/* 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/. */
+
+"use strict";
+
+/**
+ * <ul>
+ *  <payment-details-item
+                          label="Some item"
+                          amount-value="1.00"
+                          amount-currency="USD"></payment-details-item>
+ * </ul>
+ */
+
+/* global ObservedPropertiesMixin */
+
+class PaymentDetailsItem extends ObservedPropertiesMixin(HTMLElement) {
+  static get observedAttributes() {
+    return [
+      "label",
+      "amount-currency",
+      "amount-value",
+    ];
+  }
+
+  constructor() {
+    super();
+    this._label = document.createElement("span");
+    this._label.classList.add("label");
+    this._currencyAmount = document.createElement("currency-amount");
+  }
+
+  connectedCallback() {
+    this.appendChild(this._label);
+    this.appendChild(this._currencyAmount);
+
+    if (super.connectedCallback) {
+      super.connectedCallback();
+    }
+  }
+
+  render() {
+    this._currencyAmount.value = this.amountValue;
+    this._currencyAmount.currency = this.amountCurrency;
+    this._label.textContent = this.label;
+  }
+}
+
+customElements.define("payment-details-item", PaymentDetailsItem);
+
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/res/containers/order-details.css
@@ -0,0 +1,51 @@
+order-details {
+  display: grid;
+  grid-template-columns: 20% auto 10rem;
+  grid-gap: 1em;
+  margin: 1px 4vw;
+}
+
+order-details > ul {
+  list-style-type: none;
+  margin: 1em 0;
+  padding: 0;
+  display: contents;
+}
+
+order-details payment-details-item {
+  margin: 1px 0;
+  display: contents;
+}
+payment-details-item .label {
+  grid-column-start: 1;
+  grid-column-end: 3;
+}
+payment-details-item currency-amount {
+  grid-column-start: 3;
+  grid-column-end: 4;
+}
+
+order-details .footer-items-list:not(:empty):before {
+  border: 1px solid GrayText;
+  display: block;
+  content: "";
+  grid-column-start: 1;
+  grid-column-end: 4;
+}
+
+order-details > .details-total {
+  margin: 1px 0;
+  display: contents;
+}
+
+.details-total > .label {
+  margin: 0;
+  font-size: large;
+  grid-column-start: 2;
+  grid-column-end: 3;
+  text-align: end;
+}
+.details-total > currency-amount {
+  font-size: large;
+  text-align: start;
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/res/containers/order-details.js
@@ -0,0 +1,111 @@
+/* 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, PaymentRequest */
+
+"use strict";
+
+/**
+ * <order-details></order-details>
+ */
+
+class OrderDetails extends PaymentStateSubscriberMixin(HTMLElement) {
+  connectedCallback() {
+    if (!this._contents) {
+      let template = document.getElementById("order-details-template");
+      let contents = this._contents = document.importNode(template.content, true);
+
+      this._mainItemsList = contents.querySelector(".main-list");
+      this._footerItemsList = contents.querySelector(".footer-items-list");
+      this._totalAmount = contents.querySelector(".details-total > currency-amount");
+
+      this.appendChild(this._contents);
+    }
+    super.connectedCallback();
+  }
+
+  get mainItemsList() {
+    return this._mainItemsList;
+  }
+
+  get footerItemsList() {
+    return this._footerItemsList;
+  }
+
+  get totalAmountElem() {
+    return this._totalAmount;
+  }
+
+  static _emptyList(listEl) {
+    while (listEl.lastChild) {
+      listEl.removeChild(listEl.lastChild);
+    }
+  }
+
+  static _populateList(listEl, items) {
+    let fragment = document.createDocumentFragment();
+    for (let item of items) {
+      let row = document.createElement("payment-details-item");
+      row.label = item.label;
+      row.amountValue = item.amount.value;
+      row.amountCurrency = item.amount.currency;
+      fragment.appendChild(row);
+    }
+    listEl.appendChild(fragment);
+    return listEl;
+  }
+
+  render(state) {
+    let request = state.request;
+    let totalItem = request.paymentDetails.totalItem;
+
+    OrderDetails._emptyList(this.mainItemsList);
+    OrderDetails._emptyList(this.footerItemsList);
+
+    let mainItems = OrderDetails._getMainListItems(request);
+    if (mainItems.length) {
+      OrderDetails._populateList(this.mainItemsList, mainItems);
+    }
+
+    let footerItems = OrderDetails._getFooterListItems(request);
+    if (footerItems.length) {
+      OrderDetails._populateList(this.footerItemsList, footerItems);
+    }
+
+    this.totalAmountElem.value = totalItem.amount.value;
+    this.totalAmountElem.currency = totalItem.amount.currency;
+  }
+
+  /**
+   * Determine if a display item should belong in the footer list.
+   * This uses the proposed "type" property, tracked at:
+   * https://github.com/w3c/payment-request/issues/163
+   *
+   * @param {object} item - Data representing a PaymentItem
+   * @returns {boolean}
+   */
+  static isFooterItem(item) {
+    return item.type == "tax";
+  }
+
+  static _getMainListItems(request) {
+    let items = request.paymentDetails.displayItems;
+    if (Array.isArray(items) && items.length) {
+      let predicate = item => !OrderDetails.isFooterItem(item);
+      return request.paymentDetails.displayItems.filter(predicate);
+    }
+    return [];
+  }
+
+  static _getFooterListItems(request) {
+    let items = request.paymentDetails.displayItems;
+    if (Array.isArray(items) && items.length) {
+      let predicate = OrderDetails.isFooterItem;
+      return request.paymentDetails.displayItems.filter(predicate);
+    }
+    return [];
+  }
+}
+
+customElements.define("order-details", OrderDetails);
--- a/toolkit/components/payments/res/debugging.js
+++ b/toolkit/components/payments/res/debugging.js
@@ -74,16 +74,24 @@ let REQUEST_2 = {
       },
       {
         label: "Circle",
         amount: {
           currency: "EUR",
           value: "10.50",
         },
       },
+      {
+        label: "Tax",
+        type: "tax",
+        amount: {
+          currency: "USD",
+          value: "1.50",
+        },
+      },
     ],
     shippingOptions: [
       {
         id: "123",
         label: "Fast (default)",
         amount: {
           currency: "USD",
           value: 10,
--- a/toolkit/components/payments/res/mixins/PaymentStateSubscriberMixin.js
+++ b/toolkit/components/payments/res/mixins/PaymentStateSubscriberMixin.js
@@ -17,17 +17,17 @@ let requestStore = new PaymentsStore({
   orderDetailsShowing: false,
   request: {
     tabId: null,
     topLevelPrincipal: {URI: {displayHost: null}},
     requestId: null,
     paymentMethods: [],
     paymentDetails: {
       id: null,
-      totalItem: {label: null, amount: {currency: null, value: null}},
+      totalItem: {label: null, amount: {currency: null, value: 0}},
       displayItems: [],
       shippingOptions: [],
       modifiers: null,
       error: "",
     },
     paymentOptions: {
       requestPayerName: false,
       requestPayerEmail: false,
--- a/toolkit/components/payments/res/paymentRequest.xhtml
+++ b/toolkit/components/payments/res/paymentRequest.xhtml
@@ -5,25 +5,29 @@
 <!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/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>
   <script src="mixins/PaymentStateSubscriberMixin.js"></script>
 
   <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="containers/payment-dialog.js"></script>
 
   <script src="paymentRequest.js"></script>
 
@@ -50,19 +54,30 @@
 
         <footer id="controls-container">
           <button id="cancel">Cancel</button>
           <button id="pay">Pay</button>
         </footer>
       </section>
       <section id="order-details-overlay" hidden="hidden">
         <h1>Order Details</h1>
+        <order-details></order-details>
       </section>
     </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">Total</h2>
+      <currency-amount></currency-amount>
+    </div>
+  </template>
 </head>
 <body>
   <iframe id="debugging-console"
           hidden="hidden"
           height="300"
           src="debugging.html"></iframe>
   <payment-dialog></payment-dialog>
 </body>
--- a/toolkit/components/payments/test/mochitest/mochitest.ini
+++ b/toolkit/components/payments/test/mochitest/mochitest.ini
@@ -4,25 +4,29 @@ prefs =
 support-files =
    ../../../../../testing/modules/sinon-2.3.2.js
    ../../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
    ../../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/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_rich_select.html]
 [test_ObservedPropertiesMixin.html]
 [test_PaymentStateSubscriberMixin.html]
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/test/mochitest/test_order_details.html
@@ -0,0 +1,132 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+  Test the order-details component
+-->
+<head>
+  <meta charset="utf-8">
+  <title>Test the order-details component</title>
+  <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="application/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
+  <script src="payments_common.js"></script>
+  <script src="custom-elements.min.js"></script>
+  <script src="PaymentsStore.js"></script>
+  <script src="PaymentStateSubscriberMixin.js"></script>
+  <script src="order-details.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+
+  <template id="order-details-template">
+    <ul class="main-list"></ul>
+    <ul class="footer-items-list"></ul>
+
+    <div class="details-total">
+      <h2 class="label">Total</h2>
+      <currency-amount></currency-amount>
+   </div>
+  </template>
+</head>
+<body>
+  <p id="display">
+    <order-details></order-details>
+  </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="application/javascript">
+/** Test the order-details component **/
+
+/* import-globals-from payments_common.js */
+/* import-globals-from ../../res/mixins/PaymentStateSubscriberMixin.js */
+/* import-globals-from ../../res/containers/order-details.js */
+
+let orderDetails = document.querySelector("order-details");
+let emptyState = requestStore.getState();
+
+function setup() {
+  let initialState = deepClone(emptyState);
+  requestStore.setState(initialState);
+}
+
+function deepClone(obj) {
+  return JSON.parse(JSON.stringify(obj));
+}
+
+add_task(async function isFooterItem() {
+  ok(OrderDetails.isFooterItem({
+    label: "Levy",
+    type: "tax",
+    amount: { currency: "USD", value: "1" },
+  }, "items with type of 'tax' are footer items"));
+  ok(!OrderDetails.isFooterItem({
+    label: "Levis",
+    amount: { currency: "USD", value: "1" },
+  }, "items without type of 'tax' arent footer items"));
+});
+
+add_task(async function test_initial_state() {
+  setup();
+  is(orderDetails.mainItemsList.childElementCount, 0, "main items list is initially empty");
+  is(orderDetails.footerItemsList.childElementCount, 0, "footer items list is initially empty");
+  is(orderDetails.totalAmountElem.value, 0, "total amount is 0");
+});
+
+add_task(async function test_list_population() {
+  setup();
+  let paymentDetails = requestStore.getState().request.paymentDetails;
+  paymentDetails.displayItems = [
+    {
+      label: "One",
+      amount: { currency: "USD", value: "5" },
+    },
+    {
+      label: "Two",
+      amount: { currency: "USD", value: "6" },
+    },
+    {
+      label: "Three",
+      amount: { currency: "USD", value: "7" },
+    },
+  ];
+  requestStore.setState({ paymentDetails });
+  await asyncElementRendered();
+  is(orderDetails.mainItemsList.childElementCount, 3, "main items list has correct # children");
+  is(orderDetails.footerItemsList.childElementCount, 0, "footer items list has 0 children");
+
+  paymentDetails.displayItems = [
+    {
+      label: "Levy",
+      type: "tax",
+      amount: { currency: "USD", value: "1" },
+    },
+    {
+      label: "Item",
+      amount: { currency: "USD", value: "6" },
+    },
+    {
+      label: "Thing",
+      amount: { currency: "USD", value: "7" },
+    },
+  ];
+  requestStore.setState({ paymentDetails });
+  await asyncElementRendered();
+
+  is(orderDetails.mainItemsList.childElementCount, 2, "main list has correct # children");
+  is(orderDetails.footerItemsList.childElementCount, 1, "footer list has correct # children");
+});
+
+add_task(async function test_total() {
+  let paymentDetails = requestStore.getState().request.paymentDetails;
+  paymentDetails.totalItem = { label: "foo", amount: { currency: "JPY", value: 5 }};
+  requestStore.setState({ paymentDetails });
+  await asyncElementRendered();
+
+  is(orderDetails.totalAmountElem.value, 5, "total amount gets updated");
+  is(orderDetails.totalAmountElem.currency, "JPY", "total currency gets updated");
+});
+
+</script>
+
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/test/mochitest/test_payment_details_item.html
@@ -0,0 +1,70 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the payment-details-item component
+-->
+<head>
+  <meta charset="utf-8">
+  <title>Test the payment-details-item component</title>
+  <script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
+  <script type="application/javascript" src="/tests/SimpleTest/SpawnTask.js"></script>
+  <script src="payments_common.js"></script>
+  <script src="custom-elements.min.js"></script>
+  <script src="ObservedPropertiesMixin.js"></script>
+  <script src="currency-amount.js"></script>
+  <script src="payment-details-item.js"></script>
+  <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+  <p id="display">
+    <payment-details-item id="item1"></payment-details-item>
+    <payment-details-item id="item2" label="Some item" amount-value="2" amount-currency="USD"></payment-details-item>
+  </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="application/javascript">
+/** Test the payment-details-item component **/
+
+/* import-globals-from payments_common.js */
+/* import-globals-from ../../res/components/payment-details-item.js */
+
+let item1 = document.getElementById("item1");
+let item2 = document.getElementById("item2");
+
+add_task(async function test_no_value() {
+  ok(item1, "item1 exists");
+  is(item1.textContent, "", "Initially empty");
+
+  item1.label = "New label";
+  await asyncElementRendered();
+  is(item1.getAttribute("label"), "New label", "Check @label");
+  ok(!item1.hasAttribute("amount-value"), "Check @amount-value");
+  ok(!item1.hasAttribute("amount-currency"), "Check @amount-currency");
+  is(item1.label, "New label", "Check .label");
+  is(item1.amountValue, null, "Check .amountValue");
+  is(item1.amountCurrency, null, "Check .amountCurrency");
+
+  item1.label = null;
+  await asyncElementRendered();
+  ok(!item1.hasAttribute("label"), "Setting to null should remove @label");
+  is(item1.textContent, "", "Becomes empty when label is removed");
+});
+
+add_task(async function test_initial_attribute_values() {
+  is(item2.label, "Some item", "Check .label");
+  is(item2.amountValue, "2", "Check .amountValue");
+  is(item2.amountCurrency, "USD", "Check .amountCurrency");
+});
+
+add_task(async function test_templating() {
+  ok(item2.querySelector("currency-amount"), "creates currency-amount component");
+  ok(item2.querySelector(".label"), "creates label");
+});
+
+</script>
+
+</body>
+</html>