diff options
Diffstat (limited to '')
-rw-r--r-- | browser/extensions/formautofill/content/autofillEditForms.js | 644 |
1 files changed, 644 insertions, 0 deletions
diff --git a/browser/extensions/formautofill/content/autofillEditForms.js b/browser/extensions/formautofill/content/autofillEditForms.js new file mode 100644 index 0000000000..3ed64a098a --- /dev/null +++ b/browser/extensions/formautofill/content/autofillEditForms.js @@ -0,0 +1,644 @@ +/* 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/. */ + +/* exported EditAddress, EditCreditCard */ +/* eslint-disable mozilla/balanced-listeners */ // Not relevant since the document gets unloaded. + +"use strict"; + +const { FormAutofill } = ChromeUtils.importESModule( + "resource://autofill/FormAutofill.sys.mjs" +); +const { FormAutofillUtils } = ChromeUtils.importESModule( + "resource://gre/modules/shared/FormAutofillUtils.sys.mjs" +); + +class EditAutofillForm { + constructor(elements) { + this._elements = elements; + } + + /** + * Fill the form with a record object. + * + * @param {object} [record = {}] + */ + loadRecord(record = {}) { + for (let field of this._elements.form.elements) { + let value = record[field.id]; + value = typeof value == "undefined" ? "" : value; + + if (record.guid) { + field.value = value; + } else if (field.localName == "select") { + this.setDefaultSelectedOptionByValue(field, value); + } else { + // Use .defaultValue instead of .value to avoid setting the `dirty` flag + // which triggers form validation UI. + field.defaultValue = value; + } + } + if (!record.guid) { + // Reset the dirty value flag and validity state. + this._elements.form.reset(); + } else { + for (let field of this._elements.form.elements) { + this.updatePopulatedState(field); + this.updateCustomValidity(field); + } + } + } + + setDefaultSelectedOptionByValue(select, value) { + for (let option of select.options) { + option.defaultSelected = option.value == value; + } + } + + /** + * Get a record from the form suitable for a save/update in storage. + * + * @returns {object} + */ + buildFormObject() { + let initialObject = {}; + if (this.hasMailingAddressFields) { + // Start with an empty string for each mailing-address field so that any + // fields hidden for the current country are blanked in the return value. + initialObject = { + "street-address": "", + "address-level3": "", + "address-level2": "", + "address-level1": "", + "postal-code": "", + }; + } + + return Array.from(this._elements.form.elements).reduce((obj, input) => { + if (!input.disabled) { + obj[input.id] = input.value; + } + return obj; + }, initialObject); + } + + /** + * Handle events + * + * @param {DOMEvent} event + */ + handleEvent(event) { + switch (event.type) { + case "change": { + this.handleChange(event); + break; + } + case "input": { + this.handleInput(event); + break; + } + } + } + + /** + * Handle change events + * + * @param {DOMEvent} event + */ + handleChange(event) { + this.updatePopulatedState(event.target); + } + + /** + * Handle input events + * + * @param {DOMEvent} event + */ + handleInput(event) {} + + /** + * Attach event listener + */ + attachEventListeners() { + this._elements.form.addEventListener("input", this); + } + + /** + * Set the field-populated attribute if the field has a value. + * + * @param {DOMElement} field The field that will be checked for a value. + */ + updatePopulatedState(field) { + let span = field.parentNode.querySelector(".label-text"); + if (!span) { + return; + } + span.toggleAttribute("field-populated", !!field.value.trim()); + } + + /** + * Run custom validity routines specific to the field and type of form. + * + * @param {DOMElement} field The field that will be validated. + */ + updateCustomValidity(field) {} +} + +class EditAddress extends EditAutofillForm { + /** + * @param {HTMLElement[]} elements + * @param {object} record + * @param {object} config + * @param {boolean} [config.noValidate=undefined] Whether to validate the form + */ + constructor(elements, record, config) { + super(elements); + + Object.assign(this, config); + let { form } = this._elements; + Object.assign(this._elements, { + addressLevel3Label: form.querySelector( + "#address-level3-container > .label-text" + ), + addressLevel2Label: form.querySelector( + "#address-level2-container > .label-text" + ), + addressLevel1Label: form.querySelector( + "#address-level1-container > .label-text" + ), + postalCodeLabel: form.querySelector( + "#postal-code-container > .label-text" + ), + country: form.querySelector("#country"), + }); + + this.populateCountries(); + // Need to populate the countries before trying to set the initial country. + // Also need to use this._record so it has the default country selected. + this.loadRecord(record); + this.attachEventListeners(); + + form.noValidate = !!config.noValidate; + } + + loadRecord(record) { + this._record = record; + if (!record) { + record = { + country: FormAutofill.DEFAULT_REGION, + }; + } + + let { addressLevel1Options } = FormAutofillUtils.getFormFormat( + record.country + ); + this.populateAddressLevel1(addressLevel1Options, record.country); + + super.loadRecord(record); + this.loadAddressLevel1(record["address-level1"], record.country); + this.formatForm(record.country); + } + + get hasMailingAddressFields() { + let { addressFields } = this._elements.form.dataset; + return ( + !addressFields || + addressFields.trim().split(/\s+/).includes("mailing-address") + ); + } + + /** + * `mailing-address` is a special attribute token to indicate mailing fields + country. + * + * @param {object[]} mailingFieldsOrder - `fieldsOrder` from `getFormFormat` + * @param {string} addressFields - white-space-separated string of requested address fields to show + * @returns {object[]} in the same structure as `mailingFieldsOrder` but including non-mail fields + */ + static computeVisibleFields(mailingFieldsOrder, addressFields) { + if (addressFields) { + let requestedFieldClasses = addressFields.trim().split(/\s+/); + let fieldClasses = []; + if (requestedFieldClasses.includes("mailing-address")) { + fieldClasses = fieldClasses.concat(mailingFieldsOrder); + // `country` isn't part of the `mailingFieldsOrder` so add it when filling a mailing-address + requestedFieldClasses.splice( + requestedFieldClasses.indexOf("mailing-address"), + 1, + "country" + ); + } + + for (let fieldClassName of requestedFieldClasses) { + fieldClasses.push({ + fieldId: fieldClassName, + newLine: fieldClassName == "name", + }); + } + return fieldClasses; + } + + // This is the default which is shown in the management interface and includes all fields. + return mailingFieldsOrder.concat([ + { + fieldId: "country", + }, + { + fieldId: "tel", + }, + { + fieldId: "email", + newLine: true, + }, + ]); + } + + /** + * Format the form based on country. The address-level1 and postal-code labels + * should be specific to the given country. + * + * @param {string} country + */ + formatForm(country) { + const { + addressLevel3L10nId, + addressLevel2L10nId, + addressLevel1L10nId, + addressLevel1Options, + postalCodeL10nId, + fieldsOrder: mailingFieldsOrder, + postalCodePattern, + countryRequiredFields, + } = FormAutofillUtils.getFormFormat(country); + + document.l10n.setAttributes( + this._elements.addressLevel3Label, + addressLevel3L10nId + ); + document.l10n.setAttributes( + this._elements.addressLevel2Label, + addressLevel2L10nId + ); + document.l10n.setAttributes( + this._elements.addressLevel1Label, + addressLevel1L10nId + ); + document.l10n.setAttributes( + this._elements.postalCodeLabel, + postalCodeL10nId + ); + let addressFields = this._elements.form.dataset.addressFields; + let extraRequiredFields = this._elements.form.dataset.extraRequiredFields; + let fieldClasses = EditAddress.computeVisibleFields( + mailingFieldsOrder, + addressFields + ); + let requiredFields = new Set(countryRequiredFields); + if (extraRequiredFields) { + for (let extraRequiredField of extraRequiredFields.trim().split(/\s+/)) { + requiredFields.add(extraRequiredField); + } + } + this.arrangeFields(fieldClasses, requiredFields); + this.updatePostalCodeValidation(postalCodePattern); + this.populateAddressLevel1(addressLevel1Options, country); + } + + /** + * Update address field visibility and order based on libaddressinput data. + * + * @param {object[]} fieldsOrder array of objects with `fieldId` and optional `newLine` properties + * @param {Set} requiredFields Set of `fieldId` strings that mark which fields are required + */ + arrangeFields(fieldsOrder, requiredFields) { + /** + * @see FormAutofillStorage.VALID_ADDRESS_FIELDS + */ + let fields = [ + // `name` is a wrapper for the 3 name fields. + "name", + "organization", + "street-address", + "address-level3", + "address-level2", + "address-level1", + "postal-code", + "country", + "tel", + "email", + ]; + let inputs = []; + for (let i = 0; i < fieldsOrder.length; i++) { + let { fieldId, newLine } = fieldsOrder[i]; + + let container = this._elements.form.querySelector( + `#${fieldId}-container` + ); + let containerInputs = [ + ...container.querySelectorAll("input, textarea, select"), + ]; + containerInputs.forEach(function (input) { + input.disabled = false; + // libaddressinput doesn't list 'country' or 'name' as required. + // The additional-name field should never get marked as required. + input.required = + (fieldId == "country" || + fieldId == "name" || + requiredFields.has(fieldId)) && + input.id != "additional-name"; + }); + inputs.push(...containerInputs); + container.style.display = "flex"; + container.style.order = i; + container.style.pageBreakAfter = newLine ? "always" : "auto"; + // Remove the field from the list of fields + fields.splice(fields.indexOf(fieldId), 1); + } + for (let i = 0; i < inputs.length; i++) { + // Assign tabIndex starting from 1 + inputs[i].tabIndex = i + 1; + } + // Hide the remaining fields + for (let field of fields) { + let container = this._elements.form.querySelector(`#${field}-container`); + container.style.display = "none"; + for (let input of [ + ...container.querySelectorAll("input, textarea, select"), + ]) { + input.disabled = true; + } + } + } + + updatePostalCodeValidation(postalCodePattern) { + let postalCodeInput = this._elements.form.querySelector("#postal-code"); + if (postalCodePattern && postalCodeInput.style.display != "none") { + postalCodeInput.setAttribute("pattern", postalCodePattern); + } else { + postalCodeInput.removeAttribute("pattern"); + } + } + + /** + * Set the address-level1 value on the form field (input or select, whichever is present). + * + * @param {string} addressLevel1Value Value of the address-level1 from the autofill record + * @param {string} country The corresponding country + */ + loadAddressLevel1(addressLevel1Value, country) { + let field = this._elements.form.querySelector("#address-level1"); + + if (field.localName == "input") { + field.value = addressLevel1Value || ""; + return; + } + + let matchedSelectOption = FormAutofillUtils.findAddressSelectOption( + field, + { + country, + "address-level1": addressLevel1Value, + }, + "address-level1" + ); + if (matchedSelectOption && !matchedSelectOption.selected) { + field.value = matchedSelectOption.value; + field.dispatchEvent(new Event("input", { bubbles: true })); + field.dispatchEvent(new Event("change", { bubbles: true })); + } else if (addressLevel1Value) { + // If the option wasn't found, insert an option at the beginning of + // the select that matches the stored value. + field.insertBefore( + new Option(addressLevel1Value, addressLevel1Value, true, true), + field.firstChild + ); + } + } + + /** + * Replace the text input for address-level1 with a select dropdown if + * a fixed set of names exists. Otherwise show a text input. + * + * @param {Map?} options Map of options with regionCode -> name mappings + * @param {string} country The corresponding country + */ + populateAddressLevel1(options, country) { + let field = this._elements.form.querySelector("#address-level1"); + + if (field.dataset.country == country) { + return; + } + + if (!options) { + if (field.localName == "input") { + return; + } + + let input = document.createElement("input"); + input.setAttribute("type", "text"); + input.id = "address-level1"; + input.required = field.required; + input.disabled = field.disabled; + input.tabIndex = field.tabIndex; + field.replaceWith(input); + return; + } + + if (field.localName == "input") { + let select = document.createElement("select"); + select.id = "address-level1"; + select.required = field.required; + select.disabled = field.disabled; + select.tabIndex = field.tabIndex; + field.replaceWith(select); + field = select; + } + + field.textContent = ""; + field.dataset.country = country; + let fragment = document.createDocumentFragment(); + fragment.appendChild(new Option(undefined, undefined, true, true)); + for (let [regionCode, regionName] of options) { + let option = new Option(regionName, regionCode); + fragment.appendChild(option); + } + field.appendChild(fragment); + } + + populateCountries() { + let fragment = document.createDocumentFragment(); + // Sort countries by their visible names. + let countries = [...FormAutofill.countries.entries()].sort((e1, e2) => + e1[1].localeCompare(e2[1]) + ); + for (let [country] of countries) { + const countryName = Services.intl.getRegionDisplayNames(undefined, [ + country.toLowerCase(), + ]); + const option = new Option(countryName, country); + fragment.appendChild(option); + } + this._elements.country.appendChild(fragment); + } + + handleChange(event) { + if (event.target == this._elements.country) { + this.formatForm(event.target.value); + } + super.handleChange(event); + } + + attachEventListeners() { + this._elements.form.addEventListener("change", this); + super.attachEventListeners(); + } +} + +class EditCreditCard extends EditAutofillForm { + /** + * @param {HTMLElement[]} elements + * @param {object} record with a decrypted cc-number + * @param {object} addresses in an object with guid keys for the billing address picker. + */ + constructor(elements, record, addresses) { + super(elements); + + this._addresses = addresses; + Object.assign(this._elements, { + ccNumber: this._elements.form.querySelector("#cc-number"), + invalidCardNumberStringElement: this._elements.form.querySelector( + "#invalidCardNumberString" + ), + month: this._elements.form.querySelector("#cc-exp-month"), + year: this._elements.form.querySelector("#cc-exp-year"), + billingAddress: this._elements.form.querySelector("#billingAddressGUID"), + billingAddressRow: + this._elements.form.querySelector(".billingAddressRow"), + }); + + this.attachEventListeners(); + this.loadRecord(record, addresses); + } + + loadRecord(record, addresses, preserveFieldValues) { + // _record must be updated before generateYears and generateBillingAddressOptions are called. + this._record = record; + this._addresses = addresses; + this.generateBillingAddressOptions(preserveFieldValues); + if (!preserveFieldValues) { + // Re-generating the months will reset the selected option. + this.generateMonths(); + // Re-generating the years will reset the selected option. + this.generateYears(); + super.loadRecord(record); + } + } + + generateMonths() { + const count = 12; + + // Clear the list + this._elements.month.textContent = ""; + + // Empty month option + this._elements.month.appendChild(new Option()); + + // Populate month list. Format: "month number - month name" + let dateFormat = new Intl.DateTimeFormat(navigator.language, { + month: "long", + }).format; + for (let i = 0; i < count; i++) { + let monthNumber = (i + 1).toString(); + let monthName = dateFormat(new Date(1970, i)); + let option = new Option(); + option.value = monthNumber; + // XXX: Bug 1446164 - Localize this string. + option.textContent = `${monthNumber.padStart(2, "0")} - ${monthName}`; + this._elements.month.appendChild(option); + } + } + + generateYears() { + const count = 11; + const currentYear = new Date().getFullYear(); + const ccExpYear = this._record && this._record["cc-exp-year"]; + + // Clear the list + this._elements.year.textContent = ""; + + // Provide an empty year option + this._elements.year.appendChild(new Option()); + + if (ccExpYear && ccExpYear < currentYear) { + this._elements.year.appendChild(new Option(ccExpYear)); + } + + for (let i = 0; i < count; i++) { + let year = currentYear + i; + let option = new Option(year); + this._elements.year.appendChild(option); + } + + if (ccExpYear && ccExpYear > currentYear + count) { + this._elements.year.appendChild(new Option(ccExpYear)); + } + } + + generateBillingAddressOptions(preserveFieldValues) { + let billingAddressGUID; + if (preserveFieldValues && this._elements.billingAddress.value) { + billingAddressGUID = this._elements.billingAddress.value; + } else if (this._record) { + billingAddressGUID = this._record.billingAddressGUID; + } + + this._elements.billingAddress.textContent = ""; + + this._elements.billingAddress.appendChild(new Option("", "")); + + let hasAddresses = false; + for (let [guid, address] of Object.entries(this._addresses)) { + hasAddresses = true; + let selected = guid == billingAddressGUID; + let option = new Option( + FormAutofillUtils.getAddressLabel(address), + guid, + selected, + selected + ); + this._elements.billingAddress.appendChild(option); + } + + this._elements.billingAddressRow.hidden = !hasAddresses; + } + + attachEventListeners() { + this._elements.form.addEventListener("change", this); + super.attachEventListeners(); + } + + handleInput(event) { + // Clear the error message if cc-number is valid + if ( + event.target == this._elements.ccNumber && + FormAutofillUtils.isCCNumber(this._elements.ccNumber.value) + ) { + this._elements.ccNumber.setCustomValidity(""); + } + super.handleInput(event); + } + + updateCustomValidity(field) { + super.updateCustomValidity(field); + + // Mark the cc-number field as invalid if the number is empty or invalid. + if ( + field == this._elements.ccNumber && + !FormAutofillUtils.isCCNumber(field.value) + ) { + let invalidCardNumberString = + this._elements.invalidCardNumberStringElement.textContent; + field.setCustomValidity(invalidCardNumberString || " "); + } + } +} |