fune/browser/extensions/formautofill/content/autofillEditForms.js
Eemeli Aro 9e7ffeb5e0 Bug 1446164 - Migrate formautofill edit dialogs to Fluent. r=sgalich,fluent-reviewers,flod
Migrating the strings used by the edit dialogs also allows/requires for their migration elsewhere.

Some streamlining is applied to how autofillEditForms.js gets access to e.g. FormFillUtils methods, so that they are no longer routed via the XHTML files' script tags. The prior independence of this file from internal dependencies appears to have been in place to support its use as a part of the Payments API's UI, but that was dropped in bug 1721229.

The Fluent migration script included in this patch also covers changes from the immediately preceding patch.

The intl documentation change is a typo correction that was noticed while working on this patch.

Differential Revision: https://phabricator.services.mozilla.com/D155705
2022-09-01 20:35:37 +00:00

665 lines
20 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"),
ccType: this._elements.form.querySelector("#cc-type"),
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-populating the networks will reset the selected option.
this.populateNetworks();
// 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));
}
}
populateNetworks() {
// Clear the list
this._elements.ccType.textContent = "";
let frag = document.createDocumentFragment();
// include an empty first option
frag.appendChild(new Option("", ""));
let supportedNetworks = FormAutofillUtils.getCreditCardNetworks();
for (let id of supportedNetworks) {
const option = new Option(undefined, id);
// autofill-card-network-amex, ..., autofill-card-network-visa
option.dataset.l10nId = `autofill-card-network-${id}`;
frag.appendChild(option);
}
this._elements.ccType.appendChild(frag);
}
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 || " ");
}
}
}