From 8dd16259287f58f9273002717ec4d27e97127719 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Wed, 12 Jun 2024 07:43:14 +0200 Subject: Merging upstream version 127.0. Signed-off-by: Daniel Baumann --- .../formautofill/content/addressFormLayout.mjs | 187 ++++++ .../formautofill/content/autofillEditForms.js | 640 --------------------- .../formautofill/content/autofillEditForms.mjs | 288 ++++++++++ .../formautofill/content/editAddress.xhtml | 94 +-- .../formautofill/content/editCreditCard.xhtml | 52 +- .../extensions/formautofill/content/editDialog.js | 233 -------- .../extensions/formautofill/content/editDialog.mjs | 253 ++++++++ .../formautofill/content/manageAddresses.xhtml | 11 +- .../formautofill/content/manageCreditCards.xhtml | 11 +- .../formautofill/content/manageDialog.js | 454 --------------- .../formautofill/content/manageDialog.mjs | 474 +++++++++++++++ 11 files changed, 1257 insertions(+), 1440 deletions(-) create mode 100644 browser/extensions/formautofill/content/addressFormLayout.mjs delete mode 100644 browser/extensions/formautofill/content/autofillEditForms.js create mode 100644 browser/extensions/formautofill/content/autofillEditForms.mjs delete mode 100644 browser/extensions/formautofill/content/editDialog.js create mode 100644 browser/extensions/formautofill/content/editDialog.mjs delete mode 100644 browser/extensions/formautofill/content/manageDialog.js create mode 100644 browser/extensions/formautofill/content/manageDialog.mjs (limited to 'browser/extensions/formautofill/content') diff --git a/browser/extensions/formautofill/content/addressFormLayout.mjs b/browser/extensions/formautofill/content/addressFormLayout.mjs new file mode 100644 index 0000000000..5e48e6afaa --- /dev/null +++ b/browser/extensions/formautofill/content/addressFormLayout.mjs @@ -0,0 +1,187 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + FormAutofill: "resource://autofill/FormAutofill.sys.mjs", + FormAutofillUtils: "resource://gre/modules/shared/FormAutofillUtils.sys.mjs", +}); + +// Defines template descriptors for generating elements in convertLayoutToUI. +const fieldTemplates = { + commonAttributes(item) { + return { + id: item.fieldId, + name: item.fieldId, + required: item.required, + value: item.value ?? "", + }; + }, + input(item) { + return { + tag: "input", + type: item.type ?? "text", + ...this.commonAttributes(item), + }; + }, + textarea(item) { + return { + tag: "textarea", + ...this.commonAttributes(item), + }; + }, + select(item) { + return { + tag: "select", + children: item.options.map(({ value, text }) => ({ + tag: "option", + selected: value === item.value, + value, + text, + })), + ...this.commonAttributes(item), + }; + }, +}; + +/** + * Creates an HTML element with specified attributes and children. + * + * @param {string} tag - Tag name for the element to create. + * @param {object} options - Options object containing attributes and children. + * @param {object} options.attributes - Element's Attributes/Props (id, class, etc.) + * @param {Array} options.children - Element's children (array of objects with tag and options). + * @returns {HTMLElement} The newly created element. + */ +const createElement = (tag, { children = [], ...attributes }) => { + const element = document.createElement(tag); + + for (let [attributeName, attributeValue] of Object.entries(attributes)) { + if (attributeName in element) { + element[attributeName] = attributeValue; + } else { + element.setAttribute(attributeName, attributeValue); + } + } + + for (let { tag: childTag, ...childRest } of children) { + element.appendChild(createElement(childTag, childRest)); + } + + return element; +}; + +/** + * Generator that creates UI elements from `fields` object, using localization from `l10nStrings`. + * + * @param {Array} fields - Array of objects as returned from `FormAutofillUtils.getFormLayout`. + * @param {object} l10nStrings - Key-value pairs for field label localization. + * @yields {HTMLElement} - A localized label element with constructed from a field. + */ +function* convertLayoutToUI(fields, l10nStrings) { + for (const item of fields) { + // eslint-disable-next-line no-nested-ternary + const fieldTag = item.options + ? "select" + : item.multiline + ? "textarea" + : "input"; + + const fieldUI = { + label: { + id: `${item.fieldId}-container`, + class: `container ${item.newLine ? "new-line" : ""}`, + }, + field: fieldTemplates[fieldTag](item), + span: { + class: "label-text", + textContent: l10nStrings[item.l10nId] ?? "", + }, + }; + + const label = createElement("label", fieldUI.label); + const { tag, ...rest } = fieldUI.field; + const field = createElement(tag, rest); + label.appendChild(field); + const span = createElement("span", fieldUI.span); + label.appendChild(span); + + yield label; + } +} + +/** + * Retrieves the current form data from the current form element on the page. + * + * @returns {object} An object containing key-value pairs of form data. + */ +export const getCurrentFormData = () => { + const formElement = document.querySelector("form"); + const formData = new FormData(formElement); + return Object.fromEntries(formData.entries()); +}; + +/** + * Checks if the form can be submitted based on the number of non-empty values. + * TODO(Bug 1891734): Add address validation. Right now we don't do any validation. (2 fields mimics the old behaviour ). + * + * @returns {boolean} True if the form can be submitted + */ +export const canSubmitForm = () => { + const formData = getCurrentFormData(); + const validValues = Object.values(formData).filter(Boolean); + return validValues.length >= 2; +}; + +/** + * Generates a form layout based on record data and localization strings. + * + * @param {HTMLFormElement} formElement - Target form element. + * @param {object} record - Address record, includes at least country code defaulted to FormAutofill.DEFAULT_REGION. + * @param {object} l10nStrings - Localization strings map. + */ +export const createFormLayoutFromRecord = ( + formElement, + record = { country: lazy.FormAutofill.DEFAULT_REGION }, + l10nStrings = {} +) => { + // Always clear select values because they are not persisted between countries. + // For example from US with state NY, we don't want the address-level1 to be NY + // when changing to another country that doesn't have state options + const selects = formElement.querySelectorAll("select:not(#country)"); + for (const select of selects) { + select.value = ""; + } + + // Get old data to persist before clearing form + const formData = getCurrentFormData(); + record = { + ...record, + ...formData, + }; + + formElement.innerHTML = ""; + const fields = lazy.FormAutofillUtils.getFormLayout(record); + + const layoutGenerator = convertLayoutToUI(fields, l10nStrings); + + for (const fieldElement of layoutGenerator) { + formElement.appendChild(fieldElement); + } + + document.querySelector("#country").addEventListener( + "change", + ev => + // Allow some time for the user to type + // before we set the new country and re-render + setTimeout(() => { + record.country = ev.target.value; + createFormLayoutFromRecord(formElement, record, l10nStrings); + }, 300), + { once: true } + ); + + // Used to notify tests that the form has been updated and is ready + window.dispatchEvent(new CustomEvent("FormReadyForTests")); +}; diff --git a/browser/extensions/formautofill/content/autofillEditForms.js b/browser/extensions/formautofill/content/autofillEditForms.js deleted file mode 100644 index 290b436a64..0000000000 --- a/browser/extensions/formautofill/content/autofillEditForms.js +++ /dev/null @@ -1,640 +0,0 @@ -/* 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 - */ - handleInput(_e) {} - - /** - * 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. - input.required = - fieldId == "country" || - fieldId == "name" || - requiredFields.has(fieldId); - }); - 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 || " "); - } - } -} diff --git a/browser/extensions/formautofill/content/autofillEditForms.mjs b/browser/extensions/formautofill/content/autofillEditForms.mjs new file mode 100644 index 0000000000..ca74850acd --- /dev/null +++ b/browser/extensions/formautofill/content/autofillEditForms.mjs @@ -0,0 +1,288 @@ +/* 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/. */ + +/* eslint-disable mozilla/balanced-listeners */ // Not relevant since the document gets unloaded. + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + FormAutofillUtils: "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 + */ + handleInput(_e) {} + + /** + * 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) {} +} + +export 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( + lazy.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 && + lazy.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 && + !lazy.FormAutofillUtils.isCCNumber(field.value) + ) { + let invalidCardNumberString = + this._elements.invalidCardNumberStringElement.textContent; + field.setCustomValidity(invalidCardNumberString || " "); + } + } +} diff --git a/browser/extensions/formautofill/content/editAddress.xhtml b/browser/extensions/formautofill/content/editAddress.xhtml index 47ae4a2a3b..a23fa5ab8c 100644 --- a/browser/extensions/formautofill/content/editAddress.xhtml +++ b/browser/extensions/formautofill/content/editAddress.xhtml @@ -19,65 +19,13 @@ rel="stylesheet" href="chrome://formautofill/content/skin/editDialog.css" /> - - -
- - - -