Bug 1440499 - Implement the payerName/payerEmail/payerPhone contact picker. r?MattN
* Based on original patch by MattN
* Make stored contacts available as payer data (MattN)
* Add the address-picker element to the dialog for selecting payer details from stored contacts (MattN)
* Add a field-names attribute to the payer address-picker, populated from the request paymentOptions
* Basic CSS to selectively render address fields
* Add mochitests to verify paymentOptions result in the correct payment picker behavior
MozReview-Commit-ID: Br8i5MVyeQ3
--- a/toolkit/components/payments/content/paymentDialogWrapper.js
+++ b/toolkit/components/payments/content/paymentDialogWrapper.js
@@ -42,16 +42,43 @@ var paymentDialogWrapper = {
Ci.nsIObserver,
Ci.nsISupportsWeakReference,
]),
/**
* Note: This method is async because formAutofillStorage plans to become async.
*
* @param {string} guid
+ * @returns {object} containing only the requested payer values.
+ */
+ async _convertProfileAddressToPayerData(guid) {
+ let addressData = formAutofillStorage.addresses.get(guid);
+ if (!addressData) {
+ throw new Error(`Payer address not found: ${guid}`);
+ }
+
+ let {
+ requestPayerName,
+ requestPayerEmail,
+ requestPayerPhone,
+ } = this.request.paymentOptions;
+
+ let payerData = {
+ payerName: requestPayerName ? addressData.name : "",
+ payerEmail: requestPayerEmail ? addressData.email : "",
+ payerPhone: requestPayerPhone ? addressData.tel : "",
+ };
+
+ return payerData;
+ },
+
+ /**
+ * Note: This method is async because formAutofillStorage plans to become async.
+ *
+ * @param {string} guid
* @returns {nsIPaymentAddress}
*/
async _convertProfileAddressToPaymentAddress(guid) {
let addressData = formAutofillStorage.addresses.get(guid);
if (!addressData) {
throw new Error(`Shipping address not found: ${guid}`);
}
@@ -370,32 +397,42 @@ var paymentDialogWrapper = {
const showResponse = this.createShowResponse({
acceptStatus: Ci.nsIPaymentActionResponse.PAYMENT_REJECTED,
});
paymentSrv.respondPayment(showResponse);
window.close();
},
async onPay({
+ selectedPayerAddressGUID: payerGUID,
selectedPaymentCardGUID: paymentCardGUID,
selectedPaymentCardSecurityCode: cardSecurityCode,
}) {
let methodData = await this._convertProfileBasicCardToPaymentMethodData(paymentCardGUID,
cardSecurityCode);
if (!methodData) {
// TODO (Bug 1429265/Bug 1429205): Handle when a user hits cancel on the
// Master Password dialog.
Cu.reportError("Bug 1429265/Bug 1429205: User canceled master password entry");
return;
}
+ let {
+ payerName,
+ payerEmail,
+ payerPhone,
+ } = await this._convertProfileAddressToPayerData(payerGUID);
+
this.pay({
methodName: "basic-card",
methodData,
+ payerName,
+ payerEmail,
+ payerPhone,
});
},
pay({
payerName,
payerEmail,
payerPhone,
methodName,
--- a/toolkit/components/payments/res/components/address-option.css
+++ b/toolkit/components/payments/res/components/address-option.css
@@ -12,16 +12,22 @@ address-option {
rich-select[open] > .rich-select-popup-box > address-option {
grid-template-areas:
"name name "
"street-address street-address"
"email tel ";
}
+address-picker.payer-related > rich-select address-option {
+ grid-template-areas:
+ "name name"
+ "tel email";
+}
+
address-option > .name {
grid-area: name;
}
address-option > .street-address {
grid-area: street-address;
}
@@ -35,12 +41,28 @@ address-option > .tel {
address-option > .name,
address-option > .street-address,
address-option > .email,
address-option > .tel {
white-space: nowrap;
}
-.rich-select-selected-clone > .email,
-.rich-select-selected-clone > .tel {
+address-picker.shipping-related address-option > .email,
+address-picker.shipping-related address-option.rich-select-selected-clone > .tel {
display: none;
}
+
+/* for payer contact details:
+ * display fields selectively based on the contents of the address-fields attribute
+ */
+address-picker.payer-related address-option > .name,
+address-picker.payer-related address-option > .street-address,
+address-picker.payer-related address-option > .email,
+address-picker.payer-related address-option > .tel {
+ display: none;
+}
+
+address-picker[address-fields~='name'].payer-related address-option > .name,
+address-picker[address-fields~='email'].payer-related address-option > .email,
+address-picker[address-fields~='tel'].payer-related address-option > .tel {
+ display: inline-block;
+}
--- a/toolkit/components/payments/res/containers/address-picker.js
+++ b/toolkit/components/payments/res/containers/address-picker.js
@@ -28,21 +28,23 @@ class AddressPicker extends PaymentState
let {savedAddresses} = state;
let desiredOptions = [];
for (let [guid, address] of Object.entries(savedAddresses)) {
let optionEl = this.dropdown.getOptionByValue(guid);
if (!optionEl) {
optionEl = document.createElement("address-option");
optionEl.value = guid;
}
+
for (let [key, val] of Object.entries(address)) {
optionEl.setAttribute(key, val);
}
desiredOptions.push(optionEl);
}
+
let el = null;
while ((el = this.dropdown.popupBox.querySelector(":scope > address-option"))) {
el.remove();
}
for (let option of desiredOptions) {
this.dropdown.popupBox.appendChild(option);
}
--- a/toolkit/components/payments/res/containers/payment-dialog.js
+++ b/toolkit/components/payments/res/containers/payment-dialog.js
@@ -28,16 +28,19 @@ 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._shippingTypeLabel = contents.querySelector("#shipping-type-label");
this._shippingRelatedEls = contents.querySelectorAll(".shipping-related");
+ this._payerRelatedEls = contents.querySelectorAll(".payer-related");
+ this._payerAddressPicker = contents.querySelector("address-picker.payer-related");
+
this._errorText = contents.querySelector("#error-text");
this._disabledOverlay = contents.getElementById("disabled-overlay");
this.appendChild(contents);
super.connectedCallback();
}
@@ -64,21 +67,23 @@ class PaymentDialog extends PaymentState
}
cancelRequest() {
paymentRequest.cancel();
}
pay() {
let {
+ selectedPayerAddress,
selectedPaymentCard,
selectedPaymentCardSecurityCode,
} = this.requestStore.getState();
paymentRequest.pay({
+ selectedPayerAddressGUID: selectedPayerAddress,
selectedPaymentCardGUID: selectedPaymentCard,
selectedPaymentCardSecurityCode,
});
}
changeShippingAddress(shippingAddressGUID) {
paymentRequest.changeShippingAddress({
shippingAddressGUID,
@@ -101,16 +106,17 @@ class PaymentDialog extends PaymentState
setStateFromParent(state) {
this.requestStore.setState(state);
// Check if any foreign-key constraints were invalidated.
state = this.requestStore.getState();
let {
savedAddresses,
savedBasicCards,
+ selectedPayerAddress,
selectedPaymentCard,
selectedShippingAddress,
selectedShippingOption,
} = state;
let shippingOptions = state.request.paymentDetails.shippingOptions;
// Ensure `selectedShippingAddress` never refers to a deleted address and refers
// to an address if one exists.
@@ -143,16 +149,25 @@ class PaymentDialog extends PaymentState
if (!selectedShippingOption && shippingOptions.length) {
selectedShippingOption = shippingOptions[0].id;
}
this._cachedState.selectedShippingOption = selectedShippingOption;
this.requestStore.setState({
selectedShippingOption,
});
}
+
+
+ // Ensure `selectedPayerAddress` never refers to a deleted address and refers
+ // to an address if one exists.
+ if (!savedAddresses[selectedPayerAddress]) {
+ this.requestStore.setState({
+ selectedPayerAddress: Object.keys(savedAddresses)[0] || null,
+ });
+ }
}
_renderPayButton(state) {
this._payButton.disabled = state.changesPrevented;
switch (state.completionState) {
case "initial":
case "processing":
case "success":
@@ -191,20 +206,44 @@ class PaymentDialog extends PaymentState
let totalItem = paymentDetails.totalItem;
let totalAmountEl = this.querySelector("#total > currency-amount");
totalAmountEl.value = totalItem.amount.value;
totalAmountEl.currency = totalItem.amount.currency;
this._orderDetailsOverlay.hidden = !state.orderDetailsShowing;
this._errorText.textContent = paymentDetails.error;
+
let paymentOptions = request.paymentOptions;
for (let element of this._shippingRelatedEls) {
element.hidden = !paymentOptions.requestShipping;
}
+ let payerRequested = paymentOptions.requestPayerName ||
+ paymentOptions.requestPayerEmail ||
+ paymentOptions.requestPayerPhone;
+ for (let element of this._payerRelatedEls) {
+ element.hidden = !payerRequested;
+ }
+
+ if (payerRequested) {
+ let fieldNames = new Set(); // default: ["name", "tel", "email"]
+ if (paymentOptions.requestPayerName) {
+ fieldNames.add("name");
+ }
+ if (paymentOptions.requestPayerEmail) {
+ fieldNames.add("email");
+ }
+ if (paymentOptions.requestPayerPhone) {
+ fieldNames.add("tel");
+ }
+ this._payerAddressPicker.setAttribute("address-fields", [...fieldNames].join(" "));
+ } else {
+ this._payerAddressPicker.removeAttribute("address-fields");
+ }
+
let shippingType = paymentOptions.shippingType || "shipping";
this._shippingTypeLabel.querySelector("label").textContent =
this._shippingTypeLabel.dataset[shippingType + "AddressLabel"];
this._renderPayButton(state);
let {
changesPrevented,
--- a/toolkit/components/payments/res/debugging.js
+++ b/toolkit/components/payments/res/debugging.js
@@ -138,16 +138,17 @@ let REQUEST_2 = {
},
};
let ADDRESSES_1 = {
"48bnds6854t": {
"address-level1": "MI",
"address-level2": "Some City",
"country": "US",
+ "email": "foo@bar.com",
"guid": "48bnds6854t",
"name": "Mr. Foo",
"postal-code": "90210",
"street-address": "123 Sesame Street,\nApt 40",
"tel": "+1 519 555-5555",
},
"68gjdh354j": {
"address-level1": "CA",
--- a/toolkit/components/payments/res/mixins/PaymentStateSubscriberMixin.js
+++ b/toolkit/components/payments/res/mixins/PaymentStateSubscriberMixin.js
@@ -33,16 +33,17 @@ let requestStore = new PaymentsStore({
paymentOptions: {
requestPayerName: false,
requestPayerEmail: false,
requestPayerPhone: false,
requestShipping: false,
shippingType: "shipping",
},
},
+ selectedPayerAddress: null,
selectedPaymentCard: null,
selectedPaymentCardSecurityCode: null,
selectedShippingAddress: null,
selectedShippingOption: null,
savedAddresses: {},
savedBasicCards: {},
});
--- a/toolkit/components/payments/res/paymentRequest.xhtml
+++ b/toolkit/components/payments/res/paymentRequest.xhtml
@@ -5,16 +5,17 @@
<!DOCTYPE html [
<!ENTITY viewAllItems "View All Items">
<!ENTITY paymentSummaryTitle "Your Payment">
<!ENTITY shippingAddressLabel "Shipping Address">
<!ENTITY deliveryAddressLabel "Delivery Address">
<!ENTITY pickupAddressLabel "Pickup Address">
<!ENTITY shippingOptionsLabel "Shipping Options">
<!ENTITY paymentMethodsLabel "Payment Method">
+ <!ENTITY payerLabel "Contact Information">
<!ENTITY cancelPaymentButton.label "Cancel">
<!ENTITY approvePaymentButton.label "Pay">
<!ENTITY processingPaymentButton.label "Processing">
<!ENTITY successPaymentButton.label "Done">
<!ENTITY failPaymentButton.label "Fail">
<!ENTITY orderDetailsLabel "Order Details">
<!ENTITY orderTotalLabel "Total">
]>
@@ -78,16 +79,21 @@
data-pickup-address-label="&pickupAddressLabel;"><label></label></div>
<address-picker class="shipping-related" selected-state-key="selectedShippingAddress"></address-picker>
<div class="shipping-related"><label>&shippingOptionsLabel;</label></div>
<shipping-option-picker class="shipping-related"></shipping-option-picker>
<div><label>&paymentMethodsLabel;</label></div>
<payment-method-picker selected-state-key="selectedPaymentCard"></payment-method-picker>
+
+ <div class="payer-related"><label>&payerLabel;</label></div>
+ <address-picker class="payer-related"
+ selected-state-key="selectedPayerAddress"></address-picker>
+ <div id="error-text"></div>
</section>
<footer id="controls-container">
<button id="cancel">&cancelPaymentButton.label;</button>
<button id="pay"
data-initial-label="&approvePaymentButton.label;"
data-processing-label="&processingPaymentButton.label;"
data-fail-label="&failPaymentButton.label;"
--- a/toolkit/components/payments/test/mochitest/mochitest.ini
+++ b/toolkit/components/payments/test/mochitest/mochitest.ini
@@ -26,15 +26,16 @@ support-files =
../../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_payer_address_picker.html]
[test_payment_dialog.html]
[test_payment_details_item.html]
[test_payment_method_picker.html]
[test_rich_select.html]
[test_shipping_option_picker.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,11 +1,11 @@
"use strict";
-/* exported asyncElementRendered, promiseStateChange */
+/* exported asyncElementRendered, promiseStateChange, deepClone */
/**
* A helper to await on while waiting for an asynchronous rendering of a Custom
* Element.
* @returns {Promise}
*/
function asyncElementRendered() {
return Promise.resolve();
@@ -16,8 +16,12 @@ function promiseStateChange(store) {
store.subscribe({
stateChangeCallback(state) {
store.unsubscribe(this);
resolve(state);
},
});
});
}
+
+function deepClone(obj) {
+ return JSON.parse(JSON.stringify(obj));
+}
--- a/toolkit/components/payments/test/mochitest/test_order_details.html
+++ b/toolkit/components/payments/test/mochitest/test_order_details.html
@@ -44,20 +44,16 @@
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",
new file mode 100644
--- /dev/null
+++ b/toolkit/components/payments/test/mochitest/test_payer_address_picker.html
@@ -0,0 +1,207 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the paymentOptions address-picker
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the paymentOptions address-picker</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="ObservedPropertiesMixin.js"></script>
+ <script src="PaymentStateSubscriberMixin.js"></script>
+ <script src="payment-dialog.js"></script>
+
+ <script src="rich-select.js"></script>
+ <script src="address-picker.js"></script>
+ <script src="rich-option.js"></script>
+ <script src="address-option.js"></script>
+ <script src="currency-amount.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="/tests/SimpleTest/test.css"/>
+ <link rel="stylesheet" type="text/css" href="paymentRequest.css"/>
+</head>
+<body>
+ <p id="display">
+ <iframe id="templateFrame" src="paymentRequest.xhtml" width="0" height="0"></iframe>
+ </p>
+<div id="content" style="display: none">
+
+</div>
+<pre id="test">
+</pre>
+<script type="application/javascript">
+/** Test the payer requested details functionality **/
+
+/* import-globals-from payments_common.js */
+
+function getVisiblePickerOptions(picker) {
+ let select = picker.querySelector(":scope > rich-select");
+ let options = select.querySelectorAll("address-option");
+ let visibleOptions = Array.from(options).filter(isVisible);
+ return visibleOptions;
+}
+
+function isVisible(elem) {
+ let result = elem.getBoundingClientRect().height > 0;
+ return result;
+}
+
+function setPaymentOptions(requestStore, options) {
+ let {request} = requestStore.getState();
+ request = Object.assign({}, request, {
+ paymentOptions: options,
+ });
+ return requestStore.setState({ request });
+}
+
+const SAVED_ADDRESSES = {
+ "48bnds6854t": {
+ "address-level1": "MI",
+ "address-level2": "Some City",
+ "country": "US",
+ "guid": "48bnds6854t",
+ "name": "Mr. Foo",
+ "postal-code": "90210",
+ "street-address": "123 Sesame Street,\nApt 40",
+ "tel": "+1 519 555-5555",
+ "email": "foo@example.com",
+ },
+ "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",
+ "email": "bar@example.com",
+ },
+};
+
+let elPicker;
+let elDialog;
+let initialState;
+
+add_task(async function setup_once() {
+ let templateFrame = document.getElementById("templateFrame");
+ await SimpleTest.promiseFocus(templateFrame.contentWindow);
+
+ let displayEl = document.getElementById("display");
+ // 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);
+ displayEl.appendChild(imported);
+ }
+
+ elDialog = document.createElement("payment-dialog");
+ displayEl.appendChild(elDialog);
+ elPicker = elDialog.querySelector("address-picker.payer-related");
+
+ initialState = Object.assign({}, elDialog.requestStore.getState(), {
+ changesPrevented: false,
+ completionState: "initial",
+ orderDetailsShowing: false,
+ });
+});
+
+async function setup() {
+ // reset the store back to a known, default state
+ elDialog.requestStore.setState(deepClone(initialState));
+ await asyncElementRendered();
+}
+
+add_task(async function test_empty() {
+ await setup();
+
+ let {request, savedAddresses} = elPicker.requestStore.getState();
+ ok(!savedAddresses || !savedAddresses.length,
+ "Check initial state has no saved addresses");
+
+ let {paymentOptions} = request;
+ let payerRequested = paymentOptions.requestPayerName ||
+ paymentOptions.requestPayerEmail ||
+ paymentOptions.requestPayerPhone;
+ ok(!payerRequested, "Check initial state has no payer details requested");
+ ok(elPicker, "Check elPicker exists");
+ is(elPicker.dropdown.popupBox.children.length, 0, "Check dropdown is empty");
+ is(isVisible(elPicker), false, "The address-picker is not visible");
+});
+
+// paymentOptions properties are acurately reflected in the address-fields attribute
+add_task(async function test_visible_fields() {
+ await setup();
+ let requestStore = elPicker.requestStore;
+ setPaymentOptions(requestStore, {
+ requestPayerName: true,
+ requestPayerEmail: true,
+ requestPayerPhone: true,
+ });
+
+ requestStore.setState({
+ savedAddresses: SAVED_ADDRESSES,
+ selectedPayerAddress: "48bnds6854t",
+ });
+
+ await asyncElementRendered();
+
+ let visibleOptions = getVisiblePickerOptions(elPicker);
+ let visibleOption = visibleOptions[0];
+
+ is(elPicker.dropdown.popupBox.children.length, 2, "Check dropdown has 2 addresses");
+ is(visibleOptions.length, 1, "One option should be visible");
+ is(visibleOption.getAttribute("guid"), "48bnds6854t", "expected option is visible");
+
+ for (let fieldName of ["name", "email", "tel"]) {
+ let elem = visibleOption.querySelector(`.${fieldName}`);
+ ok(elem, `field ${fieldName} exists`);
+ ok(isVisible(elem), `field ${fieldName} is visible`);
+ }
+ ok(!isVisible(visibleOption.querySelector(".street-address")), "street-address is not visible");
+});
+
+add_task(async function test_selective_fields() {
+ await setup();
+ let requestStore = elPicker.requestStore;
+
+ requestStore.setState({
+ savedAddresses: SAVED_ADDRESSES,
+ selectedPayerAddress: "48bnds6854t",
+ });
+
+ let payerFieldVariations = [
+ {requestPayerName: true, requestPayerEmail: false, requestPayerPhone: false },
+ {requestPayerName: false, requestPayerEmail: true, requestPayerPhone: false },
+ {requestPayerName: false, requestPayerEmail: false, requestPayerPhone: true },
+ {requestPayerName: true, requestPayerEmail: true, requestPayerPhone: false },
+ {requestPayerName: false, requestPayerEmail: true, requestPayerPhone: true },
+ {requestPayerName: true, requestPayerEmail: false, requestPayerPhone: true },
+ ];
+
+ for (let payerFields of payerFieldVariations) {
+ setPaymentOptions(requestStore, payerFields);
+ await asyncElementRendered();
+
+ let visibleOption = getVisiblePickerOptions(elPicker)[0];
+ let elName = visibleOption.querySelector(".name");
+ let elEmail = visibleOption.querySelector(".email");
+ let elPhone = visibleOption.querySelector(".tel");
+
+ is(isVisible(elName), payerFields.requestPayerName,
+ "name field is correctly toggled");
+ is(isVisible(elEmail), payerFields.requestPayerEmail,
+ "email field is correctly toggled");
+ is(isVisible(elPhone), payerFields.requestPayerPhone,
+ "tel field is correctly toggled");
+ }
+});
+</script>
+
+</body>
+</html>