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
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>