/* 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-globals-from ../../../../../browser/extensions/formautofill/content/autofillEditForms.js*/ import LabelledCheckbox from "../components/labelled-checkbox.js"; import PaymentRequestPage from "../components/payment-request-page.js"; import PaymentStateSubscriberMixin from "../mixins/PaymentStateSubscriberMixin.js"; import paymentRequest from "../paymentRequest.js"; import HandleEventMixin from "../mixins/HandleEventMixin.js"; /* import-globals-from ../unprivileged-fallbacks.js */ /** * * * Don't use document.getElementById or document.querySelector* to access form * elements, use querySelector on `this` or `this.form` instead so that elements * can be found before the element is connected. * * XXX: Bug 1446164 - This form isn't localized when used via this custom element * as it will be much easier to share the logic once we switch to Fluent. */ export default class AddressForm extends HandleEventMixin( PaymentStateSubscriberMixin(PaymentRequestPage) ) { constructor() { super(); this.genericErrorText = document.createElement("div"); this.genericErrorText.setAttribute("aria-live", "polite"); this.genericErrorText.classList.add("page-error"); this.cancelButton = document.createElement("button"); this.cancelButton.className = "cancel-button"; this.cancelButton.addEventListener("click", this); this.backButton = document.createElement("button"); this.backButton.className = "back-button"; this.backButton.addEventListener("click", this); this.saveButton = document.createElement("button"); this.saveButton.className = "save-button primary"; this.saveButton.addEventListener("click", this); this.persistCheckbox = new LabelledCheckbox(); this.persistCheckbox.className = "persist-checkbox"; // Combination of AddressErrors and PayerErrors as keys this._errorFieldMap = { addressLine: "#street-address", city: "#address-level2", country: "#country", dependentLocality: "#address-level3", email: "#email", // Bug 1472283 is on file to support // additional-name and family-name. // XXX: For now payer name errors go on the family-name and address-errors // go on the given-name so they don't overwrite each other. name: "#family-name", organization: "#organization", phone: "#tel", postalCode: "#postal-code", // Bug 1472283 is on file to support // additional-name and family-name. recipient: "#given-name", region: "#address-level1", // Bug 1474905 is on file to properly support regionCode. See // full note in paymentDialogWrapper.js regionCode: "#address-level1", }; // The markup is shared with form autofill preferences. let url = "formautofill/editAddress.xhtml"; this.promiseReady = this._fetchMarkup(url).then(doc => { this.form = doc.getElementById("form"); return this.form; }); } _fetchMarkup(url) { return new Promise((resolve, reject) => { let xhr = new XMLHttpRequest(); xhr.responseType = "document"; xhr.addEventListener("error", reject); xhr.addEventListener("load", evt => { resolve(xhr.response); }); xhr.open("GET", url); xhr.send(); }); } connectedCallback() { this.promiseReady.then(form => { this.body.appendChild(form); let record = undefined; this.formHandler = new EditAddress( { form, }, record, { DEFAULT_REGION: PaymentDialogUtils.DEFAULT_REGION, getFormFormat: PaymentDialogUtils.getFormFormat, findAddressSelectOption: PaymentDialogUtils.findAddressSelectOption, countries: PaymentDialogUtils.countries, } ); // The EditAddress constructor adds `input` event listeners on the same element, // which update field validity. By adding our event listeners after this constructor, // validity will be updated before our handlers get the event this.form.addEventListener("input", this); this.form.addEventListener("invalid", this); this.form.addEventListener("change", this); // The "invalid" event does not bubble and needs to be listened for on each // form element. for (let field of this.form.elements) { field.addEventListener("invalid", this); } this.body.appendChild(this.persistCheckbox); this.body.appendChild(this.genericErrorText); this.footer.appendChild(this.cancelButton); this.footer.appendChild(this.backButton); this.footer.appendChild(this.saveButton); // Only call the connected super callback(s) once our markup is fully // connected, including the shared form fetched asynchronously. super.connectedCallback(); }); } render(state) { if (!this.id) { throw new Error("AddressForm without an id"); } let record; let { page, [this.id]: addressPage } = state; if (this.id && page && page.id !== this.id) { log.debug(`${this.id}: no need to further render inactive page`); return; } let editing = !!addressPage.guid; this.cancelButton.textContent = this.dataset.cancelButtonLabel; this.backButton.textContent = this.dataset.backButtonLabel; if (editing) { this.saveButton.textContent = this.dataset.updateButtonLabel; } else { this.saveButton.textContent = this.dataset.nextButtonLabel; } this.persistCheckbox.label = this.dataset.persistCheckboxLabel; this.persistCheckbox.infoTooltip = this.dataset.persistCheckboxInfoTooltip; this.backButton.hidden = page.onboardingWizard; this.cancelButton.hidden = !page.onboardingWizard; this.pageTitleHeading.textContent = editing ? this.dataset.titleEdit : this.dataset.titleAdd; this.genericErrorText.textContent = page.error; let addresses = paymentRequest.getAddresses(state); // If an address is selected we want to edit it. if (editing) { record = addresses[addressPage.guid]; if (!record) { throw new Error( "Trying to edit a non-existing address: " + addressPage.guid ); } // When editing an existing record, prevent changes to persistence this.persistCheckbox.hidden = true; } else { let { saveAddressDefaultChecked, } = PaymentDialogUtils.getDefaultPreferences(); if (typeof saveAddressDefaultChecked != "boolean") { throw new Error(`Unexpected non-boolean value for saveAddressDefaultChecked from PaymentDialogUtils.getDefaultPreferences(): ${typeof saveAddressDefaultChecked}`); } // Adding a new record: default persistence to the pref value when in a not-private session this.persistCheckbox.hidden = false; this.persistCheckbox.checked = state.isPrivate ? false : saveAddressDefaultChecked; } let selectedStateKey = this.getAttribute("selected-state-key").split("|"); log.debug(`${this.id}#render got selectedStateKey: ${selectedStateKey}`); if (addressPage.addressFields) { this.form.dataset.addressFields = addressPage.addressFields; } else { this.form.dataset.addressFields = "mailing-address tel"; } this.formHandler.loadRecord(record); // Add validation to some address fields this.updateRequiredState(); // Show merchant errors for the appropriate address form. let merchantFieldErrors = AddressForm.merchantFieldErrorsForForm( state, selectedStateKey ); for (let [errorName, errorSelector] of Object.entries( this._errorFieldMap )) { let errorText = ""; // Never show errors on an 'add' screen as they would be for a different address. if (editing && merchantFieldErrors) { if (errorName == "region" || errorName == "regionCode") { errorText = merchantFieldErrors.regionCode || merchantFieldErrors.region || ""; } else { errorText = merchantFieldErrors[errorName] || ""; } } let container = this.form.querySelector(errorSelector + "-container"); let field = this.form.querySelector(errorSelector); field.setCustomValidity(errorText); let span = paymentRequest.maybeCreateFieldErrorElement(container); span.textContent = errorText; } this.updateSaveButtonState(); } onChange(event) { if (event.target.id == "country") { this.updateRequiredState(); } this.updateSaveButtonState(); } onInvalid(event) { if (event.target instanceof HTMLFormElement) { this.onInvalidForm(event); } else { this.onInvalidField(event); } } onClick(evt) { switch (evt.target) { case this.cancelButton: { paymentRequest.cancel(); break; } case this.backButton: { let currentState = this.requestStore.getState(); const previousId = currentState.page.previousId; let state = { page: { id: previousId || "payment-summary", }, }; if (previousId) { state[previousId] = Object.assign({}, currentState[previousId], { preserveFieldValues: true, }); } this.requestStore.setState(state); break; } case this.saveButton: { if (this.form.checkValidity()) { this.saveRecord(); } break; } default: { throw new Error("Unexpected click target"); } } } onInput(event) { event.target.setCustomValidity(""); this.updateSaveButtonState(); } /** * @param {Event} event - "invalid" event * Note: Keep this in-sync with the equivalent version in basic-card-form.js */ onInvalidField(event) { let field = event.target; let container = field.closest(`#${field.id}-container`); let errorTextSpan = paymentRequest.maybeCreateFieldErrorElement(container); errorTextSpan.textContent = field.validationMessage; } onInvalidForm() { this.saveButton.disabled = true; } updateRequiredState() { for (let field of this.form.elements) { let container = field.closest(`#${field.id}-container`); if (field.localName == "button" || !container) { continue; } let span = container.querySelector(".label-text"); span.setAttribute( "fieldRequiredSymbol", this.dataset.fieldRequiredSymbol ); container.toggleAttribute("required", field.required && !field.disabled); } } updateSaveButtonState() { this.saveButton.disabled = !this.form.checkValidity(); } async saveRecord() { let record = this.formHandler.buildFormObject(); let currentState = this.requestStore.getState(); let { page, tempAddresses, savedBasicCards, [this.id]: addressPage, } = currentState; let editing = !!addressPage.guid; if ( editing ? addressPage.guid in tempAddresses : !this.persistCheckbox.checked ) { record.isTemporary = true; } let successStateChange; const previousId = page.previousId; if (page.onboardingWizard && !Object.keys(savedBasicCards).length) { successStateChange = { "basic-card-page": { selectedStateKey: "selectedPaymentCard", // Preserve field values as the user may have already edited the card // page and went back to the address page to make a correction. preserveFieldValues: true, }, page: { id: "basic-card-page", previousId: this.id, onboardingWizard: page.onboardingWizard, }, }; } else { successStateChange = { page: { id: previousId || "payment-summary", onboardingWizard: page.onboardingWizard, }, }; } if (previousId) { successStateChange[previousId] = Object.assign( {}, currentState[previousId] ); successStateChange[previousId].preserveFieldValues = true; } try { let { guid } = await paymentRequest.updateAutofillRecord( "addresses", record, addressPage.guid ); let selectedStateKey = this.getAttribute("selected-state-key").split("|"); if (selectedStateKey.length == 1) { Object.assign(successStateChange, { [selectedStateKey[0]]: guid, }); } else if (selectedStateKey.length == 2) { // Need to keep properties like preserveFieldValues from getting removed. let subObj = Object.assign({}, successStateChange[selectedStateKey[0]]); subObj[selectedStateKey[1]] = guid; Object.assign(successStateChange, { [selectedStateKey[0]]: subObj, }); } else { throw new Error( `selectedStateKey not supported: '${selectedStateKey}'` ); } this.requestStore.setState(successStateChange); } catch (ex) { log.warn("saveRecord: error:", ex); this.requestStore.setState({ page: { id: this.id, onboardingWizard: page.onboardingWizard, error: this.dataset.errorGenericSave, }, }); } } /** * Get the dictionary of field-specific merchant errors relevant to the * specific form identified by the state key. * @param {object} state The application state * @param {string[]} stateKey The key in state to return address errors for. * @returns {object} with keys as PaymentRequest field names and values of * merchant-provided error strings. */ static merchantFieldErrorsForForm(state, stateKey) { let { paymentDetails } = state.request; switch (stateKey.join("|")) { case "selectedShippingAddress": { return paymentDetails.shippingAddressErrors; } case "selectedPayerAddress": { return paymentDetails.payerErrors; } case "basic-card-page|billingAddressGUID": { // `paymentMethod` can be null. return ( (paymentDetails.paymentMethodErrors && paymentDetails.paymentMethodErrors.billingAddress) || {} ); } default: { throw new Error("Unknown selectedStateKey"); } } } } customElements.define("address-form", AddressForm);