fune/browser/extensions/formautofill/content/autofillEditForms.js

648 lines
19 KiB
JavaScript

/* 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.import(
"resource://autofill/FormAutofill.jsm"
);
const { FormAutofillUtils } = ChromeUtils.import(
"resource://autofill/FormAutofillUtils.jsm"
);
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 || " ");
}
}
}