forked from mirrors/gecko-dev
411 lines
12 KiB
JavaScript
411 lines
12 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/. */
|
|
|
|
import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
|
|
import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
|
|
|
|
const lazy = {};
|
|
ChromeUtils.defineESModuleGetters(lazy, {
|
|
FormAutofillAddressSection:
|
|
"resource://gre/modules/shared/FormAutofillSection.sys.mjs",
|
|
FormAutofillCreditCardSection:
|
|
"resource://gre/modules/shared/FormAutofillSection.sys.mjs",
|
|
FormAutofillHeuristics:
|
|
"resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs",
|
|
FormLikeFactory: "resource://gre/modules/FormLikeFactory.sys.mjs",
|
|
FormSection: "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs",
|
|
});
|
|
|
|
const { FIELD_STATES } = FormAutofillUtils;
|
|
|
|
/**
|
|
* Handles profile autofill for a DOM Form element.
|
|
*/
|
|
export class FormAutofillHandler {
|
|
// The window to which this form belongs
|
|
window = null;
|
|
|
|
// A WindowUtils reference of which Window the form belongs
|
|
winUtils = null;
|
|
|
|
// DOM Form element to which this object is attached
|
|
form = null;
|
|
|
|
// An array of section that are found in this form
|
|
sections = [];
|
|
|
|
// The section contains the focused input
|
|
#focusedSection = null;
|
|
|
|
// Caches the element to section mapping
|
|
#cachedSectionByElement = new WeakMap();
|
|
|
|
// Keeps track of filled state for all identified elements
|
|
#filledStateByElement = new WeakMap();
|
|
/**
|
|
* Array of collected data about relevant form fields. Each item is an object
|
|
* storing the identifying details of the field and a reference to the
|
|
* originally associated element from the form.
|
|
*
|
|
* The "section", "addressType", "contactType", and "fieldName" values are
|
|
* used to identify the exact field when the serializable data is received
|
|
* from the backend. There cannot be multiple fields which have
|
|
* the same exact combination of these values.
|
|
*
|
|
* A direct reference to the associated element cannot be sent to the user
|
|
* interface because processing may be done in the parent process.
|
|
*/
|
|
fieldDetails = null;
|
|
|
|
/**
|
|
* Initialize the form from `FormLike` object to handle the section or form
|
|
* operations.
|
|
*
|
|
* @param {FormLike} form Form that need to be auto filled
|
|
* @param {Function} onFormSubmitted Function that can be invoked
|
|
* to simulate form submission. Function is passed
|
|
* four arguments: (1) a FormLike for the form being
|
|
* submitted, (2) the reason for infering the form
|
|
* submission (3) the corresponding Window, and (4)
|
|
* the responsible FormAutofillHandler.
|
|
* @param {Function} onAutofillCallback Function that can be invoked
|
|
* when we want to suggest autofill on a form.
|
|
*/
|
|
constructor(form, onFormSubmitted = () => {}, onAutofillCallback = () => {}) {
|
|
this._updateForm(form);
|
|
|
|
this.window = this.form.rootElement.ownerGlobal;
|
|
this.winUtils = this.window.windowUtils;
|
|
|
|
// Enum for form autofill MANUALLY_MANAGED_STATES values
|
|
this.FIELD_STATE_ENUM = {
|
|
// not themed
|
|
[FIELD_STATES.NORMAL]: null,
|
|
// highlighted
|
|
[FIELD_STATES.AUTO_FILLED]: "autofill",
|
|
// highlighted && grey color text
|
|
[FIELD_STATES.PREVIEW]: "-moz-autofill-preview",
|
|
};
|
|
|
|
/**
|
|
* This function is used if the form handler (or one of its sections)
|
|
* determines that it needs to act as if the form had been submitted.
|
|
*/
|
|
this.onFormSubmitted = formSubmissionReason => {
|
|
onFormSubmitted(this.form, formSubmissionReason, this.window, this);
|
|
};
|
|
|
|
this.onAutofillCallback = onAutofillCallback;
|
|
|
|
ChromeUtils.defineLazyGetter(this, "log", () =>
|
|
FormAutofill.defineLogGetter(this, "FormAutofillHandler")
|
|
);
|
|
}
|
|
|
|
handleEvent(event) {
|
|
switch (event.type) {
|
|
case "input": {
|
|
if (!event.isTrusted) {
|
|
return;
|
|
}
|
|
const target = event.target;
|
|
const targetFieldDetail = this.getFieldDetailByElement(target);
|
|
const isCreditCardField = FormAutofillUtils.isCreditCardField(
|
|
targetFieldDetail.fieldName
|
|
);
|
|
|
|
// If the user manually blanks a credit card field, then
|
|
// we want the popup to be activated.
|
|
if (
|
|
!HTMLSelectElement.isInstance(target) &&
|
|
isCreditCardField &&
|
|
target.value === ""
|
|
) {
|
|
this.onAutofillCallback();
|
|
}
|
|
|
|
if (this.getFilledStateByElement(target) == FIELD_STATES.NORMAL) {
|
|
return;
|
|
}
|
|
|
|
this.changeFieldState(targetFieldDetail, FIELD_STATES.NORMAL);
|
|
const section = this.getSectionByElement(targetFieldDetail.element);
|
|
section?.clearFilled(targetFieldDetail);
|
|
}
|
|
}
|
|
}
|
|
|
|
set focusedInput(element) {
|
|
const section = this.getSectionByElement(element);
|
|
if (!section) {
|
|
return;
|
|
}
|
|
|
|
this.#focusedSection = section;
|
|
this.#focusedSection.focusedInput = element;
|
|
}
|
|
|
|
getSectionByElement(element) {
|
|
const section =
|
|
this.#cachedSectionByElement.get(element) ??
|
|
this.sections.find(s => s.getFieldDetailByElement(element));
|
|
if (!section) {
|
|
return null;
|
|
}
|
|
|
|
this.#cachedSectionByElement.set(element, section);
|
|
return section;
|
|
}
|
|
|
|
getFieldDetailByElement(element) {
|
|
for (const section of this.sections) {
|
|
const detail = section.getFieldDetailByElement(element);
|
|
if (detail) {
|
|
return detail;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
get activeSection() {
|
|
return this.#focusedSection;
|
|
}
|
|
|
|
/**
|
|
* Check the form is necessary to be updated. This function should be able to
|
|
* detect any changes including all control elements in the form.
|
|
*
|
|
* @param {HTMLElement} element The element supposed to be in the form.
|
|
* @returns {boolean} FormAutofillHandler.form is updated or not.
|
|
*/
|
|
updateFormIfNeeded(element) {
|
|
// When the following condition happens, FormAutofillHandler.form should be
|
|
// updated:
|
|
// * The count of form controls is changed.
|
|
// * When the element can not be found in the current form.
|
|
//
|
|
// However, we should improve the function to detect the element changes.
|
|
// e.g. a tel field is changed from type="hidden" to type="tel".
|
|
|
|
let _formLike;
|
|
const getFormLike = () => {
|
|
if (!_formLike) {
|
|
_formLike = lazy.FormLikeFactory.createFromField(element);
|
|
}
|
|
return _formLike;
|
|
};
|
|
|
|
const currentForm = element.form ?? getFormLike();
|
|
if (currentForm.elements.length != this.form.elements.length) {
|
|
this.log.debug("The count of form elements is changed.");
|
|
this._updateForm(getFormLike());
|
|
return true;
|
|
}
|
|
|
|
if (!this.form.elements.includes(element)) {
|
|
this.log.debug("The element can not be found in the current form.");
|
|
this._updateForm(getFormLike());
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Update the form with a new FormLike, and the related fields should be
|
|
* updated or clear to ensure the data consistency.
|
|
*
|
|
* @param {FormLike} form a new FormLike to replace the original one.
|
|
*/
|
|
_updateForm(form) {
|
|
this.form = form;
|
|
|
|
this.fieldDetails = null;
|
|
|
|
this.sections = [];
|
|
this.#cachedSectionByElement = new WeakMap();
|
|
}
|
|
|
|
/**
|
|
* Set fieldDetails from the form about fields that can be autofilled.
|
|
*
|
|
* @returns {Array} The valid address and credit card details.
|
|
*/
|
|
collectFormFields(ignoreInvalid = true) {
|
|
const sections = lazy.FormAutofillHeuristics.getFormInfo(this.form);
|
|
const allValidDetails = [];
|
|
for (const section of sections) {
|
|
// We don't support csc field, so remove csc fields from section
|
|
const fieldDetails = section.fieldDetails.filter(
|
|
f => !["cc-csc"].includes(f.fieldName)
|
|
);
|
|
if (!fieldDetails.length) {
|
|
continue;
|
|
}
|
|
|
|
let autofillableSection;
|
|
if (section.type == lazy.FormSection.ADDRESS) {
|
|
autofillableSection = new lazy.FormAutofillAddressSection(
|
|
fieldDetails,
|
|
this
|
|
);
|
|
} else {
|
|
autofillableSection = new lazy.FormAutofillCreditCardSection(
|
|
fieldDetails,
|
|
this
|
|
);
|
|
}
|
|
|
|
// Do not include section that is either disabled or invalid.
|
|
// We only include invalid section for testing purpose.
|
|
if (
|
|
!autofillableSection.isEnabled() ||
|
|
(ignoreInvalid && !autofillableSection.isValidSection())
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
this.sections.push(autofillableSection);
|
|
allValidDetails.push(...autofillableSection.fieldDetails);
|
|
}
|
|
|
|
this.fieldDetails = allValidDetails;
|
|
return allValidDetails;
|
|
}
|
|
|
|
#hasFilledSection() {
|
|
return this.sections.some(section => section.isFilled());
|
|
}
|
|
|
|
getFilledStateByElement(element) {
|
|
return this.#filledStateByElement.get(element);
|
|
}
|
|
|
|
/**
|
|
* Change the state of a field to correspond with different presentations.
|
|
*
|
|
* @param {object} fieldDetail
|
|
* A fieldDetail of which its element is about to update the state.
|
|
* @param {string} nextState
|
|
* Used to determine the next state
|
|
*/
|
|
changeFieldState(fieldDetail, nextState) {
|
|
const element = fieldDetail.element;
|
|
if (!element) {
|
|
this.log.warn(
|
|
fieldDetail.fieldName,
|
|
"is unreachable while changing state"
|
|
);
|
|
return;
|
|
}
|
|
if (!(nextState in this.FIELD_STATE_ENUM)) {
|
|
this.log.warn(
|
|
fieldDetail.fieldName,
|
|
"is trying to change to an invalid state"
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (this.#filledStateByElement.get(element) == nextState) {
|
|
return;
|
|
}
|
|
|
|
let nextStateValue = null;
|
|
for (const [state, mmStateValue] of Object.entries(this.FIELD_STATE_ENUM)) {
|
|
// The NORMAL state is simply the absence of other manually
|
|
// managed states so we never need to add or remove it.
|
|
if (!mmStateValue) {
|
|
continue;
|
|
}
|
|
|
|
if (state == nextState) {
|
|
nextStateValue = mmStateValue;
|
|
} else {
|
|
this.winUtils.removeManuallyManagedState(element, mmStateValue);
|
|
}
|
|
}
|
|
|
|
if (nextStateValue) {
|
|
this.winUtils.addManuallyManagedState(element, nextStateValue);
|
|
}
|
|
|
|
if (nextState == FIELD_STATES.AUTO_FILLED) {
|
|
element.addEventListener("input", this, { mozSystemGroup: true });
|
|
}
|
|
|
|
this.#filledStateByElement.set(element, nextState);
|
|
}
|
|
|
|
/**
|
|
* Processes form fields that can be autofilled, and populates them with the
|
|
* profile provided by backend.
|
|
*
|
|
* @param {object} profile
|
|
* A profile to be filled in.
|
|
*/
|
|
async autofillFormFields(profile) {
|
|
const noFilledSectionsPreviously = !this.#hasFilledSection();
|
|
await this.activeSection.autofillFields(profile);
|
|
|
|
const onChangeHandler = e => {
|
|
if (!e.isTrusted) {
|
|
return;
|
|
}
|
|
if (e.type == "reset") {
|
|
this.sections.map(section => section.resetFieldStates());
|
|
}
|
|
// Unregister listeners once no field is in AUTO_FILLED state.
|
|
if (!this.#hasFilledSection()) {
|
|
this.form.rootElement.removeEventListener("input", onChangeHandler, {
|
|
mozSystemGroup: true,
|
|
});
|
|
this.form.rootElement.removeEventListener("reset", onChangeHandler, {
|
|
mozSystemGroup: true,
|
|
});
|
|
}
|
|
};
|
|
|
|
if (noFilledSectionsPreviously) {
|
|
// Handle the highlight style resetting caused by user's correction afterward.
|
|
this.log.debug("register change handler for filled form:", this.form);
|
|
this.form.rootElement.addEventListener("input", onChangeHandler, {
|
|
mozSystemGroup: true,
|
|
});
|
|
this.form.rootElement.addEventListener("reset", onChangeHandler, {
|
|
mozSystemGroup: true,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Collect the filled sections within submitted form and convert all the valid
|
|
* field data into multiple records.
|
|
*
|
|
* @returns {object} records
|
|
* {Array.<Object>} records.address
|
|
* {Array.<Object>} records.creditCard
|
|
*/
|
|
createRecords() {
|
|
const records = {
|
|
address: [],
|
|
creditCard: [],
|
|
};
|
|
|
|
for (const section of this.sections) {
|
|
const secRecord = section.createRecord();
|
|
if (!secRecord) {
|
|
continue;
|
|
}
|
|
if (section instanceof lazy.FormAutofillAddressSection) {
|
|
records.address.push(secRecord);
|
|
} else if (section instanceof lazy.FormAutofillCreditCardSection) {
|
|
records.creditCard.push(secRecord);
|
|
} else {
|
|
throw new Error("Unknown section type");
|
|
}
|
|
}
|
|
|
|
return records;
|
|
}
|
|
}
|