Bug 1429181 - Consider supportedNetworks when determining if payment method is valid. r=MattN

* A new accepted-cards element to represent the labeled list of card icons
* Add the accepted cards section to the summary and card add/edit page
* mochitest for the accepted-cards element
* Make cc-type a required field and validate it against the list of supported networks
* Add verification of the pay button disabling when card network is not supported

Depends on D5823

Differential Revision: https://phabricator.services.mozilla.com/D5824

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Sam Foster 2018-09-14 20:28:41 +00:00
Родитель a3ee1b5ad2
Коммит 3b20596ca5
17 изменённых файлов: 334 добавлений и 9 удалений

Просмотреть файл

@ -13,6 +13,7 @@ browser.jar:
res/payments (res/paymentRequest.*)
res/payments/components/ (res/components/*.css)
res/payments/components/ (res/components/*.js)
res/payments/components/ (res/components/*.svg)
res/payments/containers/ (res/containers/*.js)
res/payments/containers/ (res/containers/*.css)
res/payments/containers/ (res/containers/*.svg)

Просмотреть файл

@ -0,0 +1,42 @@
accepted-cards {
margin: 1em 0;
}
.accepted-cards-label {
display: inline-block;
margin-inline-end: 1em;
color: GrayText;
font-size: smaller;
}
.accepted-cards-list {
display: inline-block;
list-style-type: none;
margin: 0;
padding: 0;
}
.accepted-cards-list > .accepted-cards-item {
display: inline-block;
width: 47px;
height: 30px;
padding: 0;
text-align: center;
margin-inline-end: 8px;
background-repeat: no-repeat;
background-position: center;
}
/* placeholders for specific card icons we don't yet have assets for */
.accepted-cards-item[data-network-id] {
background-image: url("./card-icon.svg");
}
.accepted-cards-item[data-network-id]:after {
content: attr(data-network-id);
padding: 4px;
text-align: center;
font-size: 0.7rem;
display: block;
overflow: hidden;
width: 36px;
}

Просмотреть файл

@ -0,0 +1,54 @@
/* 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/. */
import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
import paymentRequest from "../paymentRequest.js";
/* import-globals-from ../unprivileged-fallbacks.js */
/**
* <accepted-cards></accepted-cards>
*/
export default class AcceptedCards extends PaymentStateSubscriberMixin(HTMLElement) {
constructor() {
super();
this._listEl = document.createElement("ul");
this._listEl.classList.add("accepted-cards-list");
this._labelEl = document.createElement("span");
this._labelEl.classList.add("accepted-cards-label");
}
connectedCallback() {
this.label = this.getAttribute("label");
this.appendChild(this._labelEl);
this._listEl.textContent = "";
let allNetworks = PaymentDialogUtils.getCreditCardNetworks();
for (let network of allNetworks) {
let item = document.createElement("li");
item.classList.add("accepted-cards-item");
item.dataset.networkId = network;
this._listEl.appendChild(item);
}
this.appendChild(this._listEl);
// Only call the connected super callback(s) once our markup is fully
// connected
super.connectedCallback();
}
render(state) {
let acceptedNetworks = paymentRequest.getAcceptedNetworks(state.request);
for (let item of this._listEl.children) {
let network = item.dataset.networkId;
item.hidden = !(network && acceptedNetworks.includes(network));
}
}
set label(value) {
this._labelEl.textContent = value;
}
}
customElements.define("accepted-cards", AcceptedCards);

Просмотреть файл

@ -17,8 +17,8 @@ export default class BasicCardOption extends ObservedPropertiesMixin(RichOption)
"cc-exp",
"cc-name",
"cc-number",
"cc-type",
"guid",
"type", // XXX Bug 1429181.
];
}
@ -29,14 +29,14 @@ export default class BasicCardOption extends ObservedPropertiesMixin(RichOption)
constructor() {
super();
for (let name of ["cc-name", "cc-number", "cc-exp", "type"]) {
for (let name of ["cc-name", "cc-number", "cc-exp", "cc-type"]) {
this[`_${name}`] = document.createElement("span");
this[`_${name}`].classList.add(name);
}
}
connectedCallback() {
for (let name of ["cc-name", "cc-number", "cc-exp", "type"]) {
for (let name of ["cc-name", "cc-number", "cc-exp", "cc-type"]) {
this.appendChild(this[`_${name}`]);
}
super.connectedCallback();
@ -47,14 +47,16 @@ export default class BasicCardOption extends ObservedPropertiesMixin(RichOption)
let ccNumber = basicCard["cc-number"] || "";
let ccExp = basicCard["cc-exp"] || "";
let ccName = basicCard["cc-name"] || "";
return ccNumber + " " + ccExp + " " + ccName;
// XXX: Bug 1491040, displaying cc-type in this context may need its own localized string
let ccType = basicCard["cc-type"] || "";
return `${ccType} ${ccNumber} ${ccExp} ${ccName}`;
}
render() {
this["_cc-name"].textContent = this.ccName;
this["_cc-number"].textContent = this.ccNumber;
this["_cc-exp"].textContent = this.ccExp;
this._type.textContent = this.type;
this["_cc-type"].textContent = this.ccType;
}
}

Просмотреть файл

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 47 30">
<rect x="0" y="0" width="47" height="22" rx="4" ry="4" fill="#000" fill-opacity="0.2">
</rect>
<rect x="0" y="5" width="47" height="12" fill="#fff" fill-opacity="1">
</rect>
</svg>

После

Ширина:  |  Высота:  |  Размер: 250 B

Просмотреть файл

@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/* import-globals-from ../../../../../browser/extensions/formautofill/content/autofillEditForms.js*/
import AcceptedCards from "../components/accepted-cards.js";
import LabelledCheckbox from "../components/labelled-checkbox.js";
import PaymentRequestPage from "../components/payment-request-page.js";
import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js";
@ -37,6 +38,8 @@ export default class BasicCardForm extends PaymentStateSubscriberMixin(PaymentRe
this.persistCheckbox = new LabelledCheckbox();
this.persistCheckbox.className = "persist-checkbox";
this.acceptedCardsList = new AcceptedCards();
// page footer
this.cancelButton = document.createElement("button");
this.cancelButton.className = "cancel-button";
@ -102,6 +105,7 @@ export default class BasicCardForm extends PaymentStateSubscriberMixin(PaymentRe
this.body.appendChild(this.persistCheckbox);
this.body.appendChild(this.genericErrorText);
this.body.appendChild(this.acceptedCardsList);
// Only call the connected super callback(s) once our markup is fully
// connected, including the shared form fetched asynchronously.
super.connectedCallback();
@ -128,6 +132,7 @@ export default class BasicCardForm extends PaymentStateSubscriberMixin(PaymentRe
this.persistCheckbox.label = this.dataset.persistCheckboxLabel;
this.addressAddLink.textContent = this.dataset.addressAddLinkLabel;
this.addressEditLink.textContent = this.dataset.addressEditLinkLabel;
this.acceptedCardsList.label = this.dataset.acceptedCardsLabel;
// The next line needs an onboarding check since we don't set previousId
// when navigating to add/edit directly from the summary page.

Просмотреть файл

@ -9,6 +9,7 @@ import paymentRequest from "../paymentRequest.js";
import "../components/currency-amount.js";
import "../components/payment-request-page.js";
import "../components/accepted-cards.js";
import "./address-picker.js";
import "./address-form.js";
import "./basic-card-form.js";
@ -52,6 +53,7 @@ export default class PaymentDialog extends PaymentStateSubscriberMixin(HTMLEleme
this._payerRelatedEls = contents.querySelectorAll(".payer-related");
this._payerAddressPicker = contents.querySelector("address-picker.payer-related");
this._paymentMethodPicker = contents.querySelector("payment-method-picker");
this._acceptedCardsList = contents.querySelector("accepted-cards");
this._header = contents.querySelector("header");
@ -346,6 +348,10 @@ export default class PaymentDialog extends PaymentStateSubscriberMixin(HTMLEleme
this._payerAddressPicker.dataset.addAddressTitle = this.dataset.payerTitleAdd;
this._payerAddressPicker.dataset.editAddressTitle = this.dataset.payerTitleEdit;
// hide the accepted cards list if the merchant didn't specify a preference
let acceptedNetworks = paymentRequest.getAcceptedNetworks(state.request);
this._acceptedCardsList.hidden = !acceptedNetworks.length;
this._renderPayButton(state);
for (let page of this._mainContainer.querySelectorAll(":scope > .page")) {

Просмотреть файл

@ -31,8 +31,6 @@ export default class PaymentMethodPicker extends RichPicker {
get fieldNames() {
let fieldNames = [...BasicCardOption.recordAttributes];
// Type is not a required field though it may be present.
fieldNames.splice(fieldNames.indexOf("type"), 1);
return fieldNames;
}
@ -76,6 +74,23 @@ export default class PaymentMethodPicker extends RichPicker {
super.render(state);
}
isSelectedOptionValid(state) {
let hasMissingFields = this.missingFieldsOfSelectedOption().length;
if (hasMissingFields) {
return false;
}
let selectedOption = this.selectedOption;
if (!selectedOption) {
return true;
}
let acceptedNetworks = paymentRequest.getAcceptedNetworks(state.request);
let selectedCard = state.savedBasicCards[selectedOption.value];
let isSupported = selectedCard["cc-type"] &&
acceptedNetworks.includes(selectedCard["cc-type"]);
return isSupported;
}
get selectedStateKey() {
return this.getAttribute("selected-state-key");
}

Просмотреть файл

@ -59,7 +59,7 @@ export default class RichPicker extends PaymentStateSubscriberMixin(HTMLElement)
this.editLink.hidden = !this.dropdown.value;
this.classList.toggle("invalid-selected-option",
this.missingFieldsOfSelectedOption().length);
!this.isSelectedOptionValid(state));
}
get selectedOption() {
@ -71,6 +71,10 @@ export default class RichPicker extends PaymentStateSubscriberMixin(HTMLElement)
return [];
}
isSelectedOptionValid() {
return !this.missingFieldsOfSelectedOption().length;
}
missingFieldsOfSelectedOption() {
let selectedOption = this.selectedOption;
if (!selectedOption) {

Просмотреть файл

@ -292,6 +292,7 @@ let BASIC_CARDS_1 = {
"cc-name": "John Smith",
"cc-exp-month": 6,
"cc-exp-year": 2024,
"cc-type": "visa",
"cc-given-name": "John",
"cc-additional-name": "",
"cc-family-name": "Smith",
@ -309,6 +310,7 @@ let BASIC_CARDS_1 = {
"cc-name": "Jane Doe",
"cc-exp-month": 5,
"cc-exp-year": 2023,
"cc-type": "mastercard",
"cc-given-name": "Jane",
"cc-additional-name": "",
"cc-family-name": "Doe",
@ -327,6 +329,7 @@ let BASIC_CARDS_1 = {
"cc-given-name": "Jane",
"cc-additional-name": "",
"cc-family-name": "Fields",
"cc-type": "discover",
},
"missing-cc-name": {
methodName: "basic-card",
@ -340,6 +343,7 @@ let BASIC_CARDS_1 = {
"cc-exp-month": 8,
"cc-exp-year": 2024,
"cc-exp": "2024-08",
"cc-type": "amex",
},
};

Просмотреть файл

@ -277,6 +277,18 @@ var paymentRequest = {
let cards = Object.assign({}, state.savedBasicCards, state.tempBasicCards);
return cards;
},
getAcceptedNetworks(request) {
let basicCardMethod = request.paymentMethods
.find(method => method.supportedMethods == "basic-card");
let merchantNetworks = basicCardMethod && basicCardMethod.data &&
basicCardMethod.data.supportedNetworks;
if (merchantNetworks && merchantNetworks.length) {
return merchantNetworks;
}
// fallback to the complete list if the merchant didn't specify
return PaymentDialogUtils.getCreditCardNetworks();
},
};
paymentRequest.init();

Просмотреть файл

@ -73,6 +73,7 @@
<!ENTITY timeoutErrorPage.doneButton.label "Close">
<!ENTITY webPaymentsBranding.label "&brandShortName; Checkout">
<!ENTITY invalidOption.label "Missing or invalid information">
<!ENTITY acceptedCards.label "Merchant accepts:">
]>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
@ -92,6 +93,7 @@
<link rel="stylesheet" href="components/basic-card-option.css"/>
<link rel="stylesheet" href="components/shipping-option.css"/>
<link rel="stylesheet" href="components/payment-details-item.css"/>
<link rel="stylesheet" href="components/accepted-cards.css"/>
<link rel="stylesheet" href="containers/address-form.css"/>
<link rel="stylesheet" href="containers/basic-card-form.css"/>
<link rel="stylesheet" href="containers/order-details.css"/>
@ -145,6 +147,7 @@
data-invalid-label="&invalidOption.label;"
label="&paymentMethodsLabel;">
</payment-method-picker>
<accepted-cards hidden="hidden" label="&acceptedCards.label;"></accepted-cards>
<address-picker class="payer-related"
label="&payerLabel;"
data-add-link-label="&payer.addLink.label;"
@ -182,6 +185,7 @@
data-update-button-label="&basicCardPage.updateButton.label;"
data-cancel-button-label="&cancelPaymentButton.label;"
data-persist-checkbox-label="&basicCardPage.persistCheckbox.label;"
data-accepted-cards-label="&acceptedCards.label;"
data-field-required-symbol="&fieldRequiredSymbol;"
hidden="hidden"></basic-card-form>

Просмотреть файл

@ -31,7 +31,7 @@ var PaymentDialogUtils = {
return `${address.name} (${address.guid})`;
},
getCreditCardNetworks(address) {
getCreditCardNetworks() {
// Shim for list of known and supported credit card network ids as exposed by
// toolkit/modules/CreditCard.jsm
return [

Просмотреть файл

@ -181,6 +181,16 @@ var PaymentTestUtils = {
EventUtils.synthesizeKey(option.textContent, {}, content.window);
},
selectPaymentOptionByGuid: guid => {
let doc = content.document;
let methodPicker = doc.querySelector("payment-method-picker");
let select = Cu.waiveXrays(methodPicker).dropdown.popupBox;
let option = select.querySelector(`[guid="${guid}"]`);
select.focus();
// eslint-disable-next-line no-undef
EventUtils.synthesizeKey(option.label, {}, content.window);
},
/**
* Click the primary button for the current page
*

Просмотреть файл

@ -202,3 +202,60 @@ add_task(async function test_localized() {
await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
});
});
add_task(async function test_supportedNetworks() {
await setupFormAutofillStorage();
await cleanupFormAutofillStorage();
let address1GUID = await addAddressRecord(PTU.Addresses.TimBL);
let visaCardGUID = await addCardRecord(Object.assign({}, PTU.BasicCards.JohnDoe, {
billingAddressGUID: address1GUID,
}));
let masterCardGUID = await addCardRecord(Object.assign({}, PTU.BasicCards.JaneMasterCard, {
billingAddressGUID: address1GUID,
}));
let cardMethod = {
supportedMethods: "basic-card",
data: {
supportedNetworks: ["visa"],
},
};
await BrowserTestUtils.withNewTab({
gBrowser,
url: BLANK_PAGE_URL,
}, async browser => {
let {win, frame} =
await setupPaymentDialog(browser, {
methodData: [cardMethod],
details,
merchantTaskFn: PTU.ContentTasks.createAndShowRequest,
}
);
await spawnPaymentDialogTask(frame, () => {
let acceptedCards = content.document.querySelector("accepted-cards");
ok(acceptedCards && !content.isHidden(acceptedCards),
"accepted-cards element is present and visible");
});
info("select the mastercard");
await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.selectPaymentOptionByGuid,
masterCardGUID);
await spawnPaymentDialogTask(frame, async () => {
ok(content.document.getElementById("pay").disabled, "pay button should be disabled");
});
info("select the visa");
await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.selectPaymentOptionByGuid,
visaCardGUID);
await spawnPaymentDialogTask(frame, async () => {
ok(!content.document.getElementById("pay").disabled, "pay button should not be disabled");
});
await spawnPaymentDialogTask(frame, PTU.DialogContentTasks.manuallyClickCancel);
await BrowserTestUtils.waitForCondition(() => win.closed, "dialog should be closed");
});
});

Просмотреть файл

@ -28,3 +28,4 @@ skip-if = !e10s
[test_ObservedPropertiesMixin.html]
[test_PaymentsStore.html]
[test_PaymentStateSubscriberMixin.html]
[test_accepted_cards.html]

Просмотреть файл

@ -0,0 +1,102 @@
<!DOCTYPE HTML>
<html>
<!--
Test the accepted-cards element
-->
<head>
<meta charset="utf-8">
<title>Test the accepted-cards element</title>
<script type="application/javascript" src="/tests/SimpleTest/SimpleTest.js"></script>
<script type="application/javascript" src="/tests/SimpleTest/EventUtils.js"></script>
<script type="application/javascript" src="/tests/SimpleTest/AddTask.js"></script>
<script src="sinon-2.3.2.js"></script>
<script src="payments_common.js"></script>
<script src="../../res/vendor/custom-elements.min.js"></script>
<script src="../../res/unprivileged-fallbacks.js"></script>
<link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"/>
<link rel="stylesheet" type="text/css" href="../../res/paymentRequest.css"/>
<link rel="stylesheet" type="text/css" href="../../res/components/accepted-cards.css"/>
</head>
<body>
<p id="display">
<accepted-cards label="Accepted:"></accepted-cards>
</p>
<div id="content" style="display: none">
</div>
<pre id="test">
</pre>
<script type="module">
/** Test the accepted-cards component **/
import "../../res/components/accepted-cards.js";
import {requestStore} from "../../res/mixins/PaymentStateSubscriberMixin.js";
let emptyState = requestStore.getState();
let acceptedElem = document.querySelector("accepted-cards");
let allNetworks = PaymentDialogUtils.getCreditCardNetworks();
add_task(async function test_reConnected() {
let itemsCount = acceptedElem.querySelectorAll(".accepted-cards-item").length;
is(itemsCount, allNetworks.length, "Same number of items as there are supported networks");
let container = acceptedElem.parentNode;
let removed = container.removeChild(acceptedElem);
container.appendChild(removed);
let newItemsCount = acceptedElem.querySelectorAll(".accepted-cards-item").length;
is(itemsCount, newItemsCount, "Number of items doesnt changed when re-connected");
});
add_task(async function test_noSupportedNetworksIndicated() {
let paymentMethods = [{
supportedMethods: "basic-card",
}];
requestStore.setState({
request: Object.assign({}, emptyState.request, {
paymentMethods,
}),
});
await asyncElementRendered();
let showingItems = acceptedElem.querySelectorAll(".accepted-cards-item:not([hidden])");
is(showingItems.length, allNetworks.length,
"Expected all items to be showing when no supportedNetworks are indicated");
for (let network of allNetworks) {
ok(acceptedElem.querySelector(`[data-network-id='${network}']:not([hidden])`),
`Item for the ${network} network expected to be visible`);
}
});
add_task(async function test_someAccepted() {
let supportedNetworks = ["discover", "amex"];
let paymentMethods = [{
supportedMethods: "basic-card",
data: {
supportedNetworks,
},
}];
requestStore.setState({
request: Object.assign({}, emptyState.request, {
paymentMethods,
}),
});
await asyncElementRendered();
let showingItems = acceptedElem.querySelectorAll(".accepted-cards-item:not([hidden])");
is(showingItems.length, 2,
"Expected 2 items to be showing when 2 supportedNetworks are indicated");
for (let network of allNetworks) {
if (supportedNetworks.includes(network)) {
ok(acceptedElem.querySelector(`[data-network-id='${network}']:not([hidden])`),
`Item for the ${network} network expected to be visible`);
} else {
ok(acceptedElem.querySelector(`[data-network-id='${network}'][hidden]`),
`Item for the ${network} network expected to be hidden`);
}
}
});
</script>
</body>
</html>