Bug 1942239 - Add option to explicitly enable incremental origin initialization in...
[gecko.git] / toolkit / components / formautofill / shared / FormAutofillHandler.sys.mjs
blobc1360197fad3ca35ceadf73e972036cc7ce3da48
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2  * License, v. 2.0. If a copy of the MPL was not distributed with this
3  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 import { FormAutofill } from "resource://autofill/FormAutofill.sys.mjs";
6 import { FormAutofillUtils } from "resource://gre/modules/shared/FormAutofillUtils.sys.mjs";
8 const lazy = {};
9 ChromeUtils.defineESModuleGetters(lazy, {
10   AddressParser: "resource://gre/modules/shared/AddressParser.sys.mjs",
11   AutofillFormFactory:
12     "resource://gre/modules/shared/AutofillFormFactory.sys.mjs",
13   CreditCard: "resource://gre/modules/CreditCard.sys.mjs",
14   FieldDetail: "resource://gre/modules/shared/FieldScanner.sys.mjs",
15   FormAutofillHeuristics:
16     "resource://gre/modules/shared/FormAutofillHeuristics.sys.mjs",
17   FormAutofillNameUtils:
18     "resource://gre/modules/shared/FormAutofillNameUtils.sys.mjs",
19   LabelUtils: "resource://gre/modules/shared/LabelUtils.sys.mjs",
20 });
22 const { FIELD_STATES } = FormAutofillUtils;
24 /**
25  * Handles profile autofill for a DOM Form element.
26  */
27 export class FormAutofillHandler {
28   // The window to which this form belongs
29   window = null;
31   // DOM Form element to which this object is attached
32   form = null;
34   // Keeps track of filled state for all identified elements
35   #filledStateByElement = new WeakMap();
37   // An object that caches the current selected option, keyed by element.
38   #matchingSelectOption = null;
40   /**
41    * Array of collected data about relevant form fields.  Each item is an object
42    * storing the identifying details of the field and a reference to the
43    * originally associated element from the form.
44    *
45    * The "section", "addressType", "contactType", and "fieldName" values are
46    * used to identify the exact field when the serializable data is received
47    * from the backend.  There cannot be multiple fields which have
48    * the same exact combination of these values.
49    *
50    * A direct reference to the associated element cannot be sent to the user
51    * interface because processing may be done in the parent process.
52    */
53   #fieldDetails = null;
55   /**
56    * Initialize the form from `FormLike` object to handle the section or form
57    * operations.
58    *
59    * @param {FormLike} form Form that need to be auto filled
60    * @param {Function} onFilledModifiedCallback Function that can be invoked
61    *                   when we want to suggest autofill on a form.
62    */
63   constructor(form, onFilledModifiedCallback = () => {}) {
64     this._updateForm(form);
66     this.window = this.form.rootElement.ownerGlobal;
68     this.onFilledModifiedCallback = onFilledModifiedCallback;
70     // The identifier generated via ContentDOMReference for the root element.
71     this.rootElementId = FormAutofillUtils.getElementIdentifier(
72       form.rootElement
73     );
75     ChromeUtils.defineLazyGetter(this, "log", () =>
76       FormAutofill.defineLogGetter(this, "FormAutofillHandler")
77     );
78   }
80   /**
81    * Retrieves the 'fieldDetails' property, ensuring it has been initialized by
82    * `setIdentifiedFieldDetails`. Throws an error if accessed before initialization.
83    *
84    * This is because 'fieldDetail'' contains information that need to be computed
85    * in the parent side first.
86    *
87    * @throws {Error} If `setIdentifiedFieldDetails` has not been called.
88    * @returns {Array<FieldDetail>}
89    *          The list of autofillable field details for this form.
90    */
91   get fieldDetails() {
92     if (!this.#fieldDetails) {
93       throw new Error(
94         `Should only use 'fieldDetails' after 'setIdentifiedFieldDetails' is called`
95       );
96     }
97     return this.#fieldDetails;
98   }
100   /**
101    * Sets the list of 'FieldDetail' objects for autofillable fields within the form.
102    *
103    * @param {Array<FieldDetail>} fieldDetails
104    *        An array of field details that has been computed on the parent side.
105    *        This method should be called before accessing `fieldDetails`.
106    */
107   setIdentifiedFieldDetails(fieldDetails) {
108     this.#fieldDetails = fieldDetails;
109   }
111   /**
112    * Determines whether 'setIdentifiedFieldDetails' has been called and the
113    * `fieldDetails` have been initialized.
114    *
115    * @returns {boolean}
116    *          True if 'fieldDetails' has been initialized; otherwise, False.
117    */
118   hasIdentifiedFields() {
119     return !!this.#fieldDetails;
120   }
122   handleEvent(event) {
123     switch (event.type) {
124       case "input": {
125         if (!event.isTrusted) {
126           return;
127         }
129         // This uses the #filledStateByElement map instead of
130         // autofillState as the state has already been cleared by the time
131         // the input event fires.
132         const fieldDetail = this.getFieldDetailByElement(event.target);
133         const previousState = this.getFilledStateByElement(event.target);
134         const newState = FIELD_STATES.NORMAL;
136         if (previousState != newState) {
137           this.changeFieldState(fieldDetail, newState);
138         }
140         this.onFilledModifiedCallback?.(fieldDetail, previousState, newState);
141       }
142     }
143   }
145   getFieldDetailByName(fieldName) {
146     return this.fieldDetails.find(detail => detail.fieldName == fieldName);
147   }
149   getFieldDetailByElement(element) {
150     return this.fieldDetails.find(detail => detail.element == element);
151   }
153   getFieldDetailByElementId(elementId) {
154     return this.fieldDetails.find(detail => detail.elementId == elementId);
155   }
157   /**
158    * Only use this API within handleEvent
159    */
160   getFilledStateByElement(element) {
161     return this.#filledStateByElement.get(element);
162   }
164   /**
165    * Check the form is necessary to be updated. This function should be able to
166    * detect any changes including all control elements in the form.
167    *
168    * @param {HTMLElement} element The element supposed to be in the form.
169    * @returns {boolean} FormAutofillHandler.form is updated or not.
170    */
171   updateFormIfNeeded(element) {
172     // When the following condition happens, FormAutofillHandler.form should be
173     // updated:
174     // * The count of form controls is changed.
175     // * When the element can not be found in the current form.
176     //
177     // However, we should improve the function to detect the element changes.
178     // e.g. a tel field is changed from type="hidden" to type="tel".
180     let _formLike;
181     const getFormLike = () => {
182       if (!_formLike) {
183         _formLike = lazy.AutofillFormFactory.createFromField(element);
184       }
185       return _formLike;
186     };
188     const currentForm = getFormLike();
189     if (currentForm.elements.length != this.form.elements.length) {
190       this.log.debug("The count of form elements is changed.");
191       this._updateForm(getFormLike());
192       return true;
193     }
195     if (!this.form.elements.includes(element)) {
196       this.log.debug("The element can not be found in the current form.");
197       this._updateForm(getFormLike());
198       return true;
199     }
201     return false;
202   }
204   /**
205    * Update the form with a new FormLike, and the related fields should be
206    * updated or clear to ensure the data consistency.
207    *
208    * @param {FormLike} form a new FormLike to replace the original one.
209    */
210   _updateForm(form) {
211     this.form = form;
213     this.#fieldDetails = null;
214   }
216   /**
217    * Collect <input>, <select>, and <iframe> elements from the specified form
218    * and return the correspond 'FieldDetail' objects.
219    *
220    * @param {formLike} formLike
221    *        The form that we collect information from.
222    * @param {boolean} includeIframe
223    *        True to add <iframe> to the returned FieldDetails array.
224    * @param {boolean} ignoreInvisibleInput
225    *        True to NOT run heuristics on invisible <input> fields.
226    *
227    * @returns {Array<FieldDeail>}
228    *        An array containing eliglble fields for autofill, also
229    *        including iframe.
230    */
231   static collectFormFieldDetails(
232     formLike,
233     includeIframe,
234     ignoreInvisibleInput = true
235   ) {
236     const fieldDetails =
237       lazy.FormAutofillHeuristics.getFormInfo(formLike, ignoreInvisibleInput) ??
238       [];
240     // 'FormLike' only contains <input> & <select>, so in order to include <iframe>
241     // in the list of 'FieldDetails', we need to search for <iframe> in the form.
242     if (!includeIframe) {
243       return fieldDetails;
244     }
246     // Insert <iframe> elements into the fieldDetails array, maintaining the element order.
247     const fieldDetailsIncludeIframe = [];
248     let index = 0;
249     const elements = formLike.rootElement.querySelectorAll(
250       "input, select, iframe"
251     );
252     for (const element of elements) {
253       if (fieldDetails[index]?.element == element) {
254         fieldDetailsIncludeIframe.push(fieldDetails[index]);
255         index++;
256       } else if (
257         element.localName == "iframe" &&
258         FormAutofillUtils.isFieldVisible(element)
259       ) {
260         // Add the <iframe> only if it is under the `formLike` element.
261         // While we use formLike.rootElement.querySelectorAll, it is still possible
262         // we find an <iframe> inside a <form> within this rootElement. In this
263         // case, we don't want to include the <iframe> in the field list.
264         if (
265           lazy.AutofillFormFactory.findRootForField(element) ==
266           formLike.rootElement
267         ) {
268           const iframeFd = lazy.FieldDetail.create(element, formLike, "iframe");
269           fieldDetailsIncludeIframe.push(iframeFd);
270         }
271       }
272     }
273     return fieldDetailsIncludeIframe;
274   }
276   /**
277    * Change the state of a field to correspond with different presentations.
278    *
279    * @param {object} fieldDetail
280    *        A fieldDetail of which its element is about to update the state.
281    * @param {string} state
282    *        The state to apply.
283    */
284   changeFieldState(fieldDetail, state) {
285     const element = fieldDetail.element;
286     if (!element) {
287       this.log.warn(
288         fieldDetail.fieldName,
289         "is unreachable while changing state"
290       );
291       return;
292     }
294     if (!Object.values(FIELD_STATES).includes(state)) {
295       this.log.warn(
296         fieldDetail.fieldName,
297         "is trying to change to an invalid state"
298       );
299       return;
300     }
302     element.autofillState = state;
303     this.#filledStateByElement.set(element, state);
305     if (state == FIELD_STATES.AUTO_FILLED) {
306       element.addEventListener("input", this, { mozSystemGroup: true });
307     }
308   }
310   /**
311    * Populates result to the preview layers with given profile.
312    *
313    * @param {Array} elementIds
314    * @param {object} profile
315    *        A profile to be previewed with
316    */
317   previewFields(elementIds, profile) {
318     this.getAdaptedProfiles([profile]);
320     for (const fieldDetail of this.fieldDetails) {
321       const element = fieldDetail.element;
323       // Skip the field if it is null or readonly or disabled
324       if (
325         !elementIds.includes(fieldDetail.elementId) ||
326         !FormAutofillUtils.isFieldAutofillable(element)
327       ) {
328         continue;
329       }
331       let value = this.getFilledValueFromProfile(fieldDetail, profile);
332       if (!value) {
333         this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
334         continue;
335       }
337       if (HTMLInputElement.isInstance(element)) {
338         if (element.value && element.value != element.defaultValue) {
339           // Skip the field if the user has already entered text and that text
340           // is not the site prefilled value.
341           continue;
342         }
343       } else if (HTMLSelectElement.isInstance(element)) {
344         // Unlike text input, select element is always previewed even if
345         // the option is already selected.
346         const option = this.matchSelectOptions(fieldDetail, profile);
347         value = option?.text ?? "";
348       } else {
349         continue;
350       }
352       element.previewValue = value?.toString().replaceAll("*", "•");
353       this.changeFieldState(fieldDetail, FIELD_STATES.PREVIEW);
354     }
355   }
357   /**
358    * Processes form fields that can be autofilled, and populates them with the
359    * profile provided by backend.
360    *
361    * @param {string} focusedId
362    *        The id of the element that triggers autofilling.
363    * @param {Array} elementIds
364    *        An array of IDs for the elements that should be autofilled.
365    * @param {object} profile
366    *        The data profile containing the values to be autofilled into the form fields.
367    */
368   fillFields(focusedId, elementIds, profile) {
369     this.getAdaptedProfiles([profile]);
371     for (const fieldDetail of this.fieldDetails) {
372       const { element, elementId } = fieldDetail;
374       if (
375         !elementIds.includes(elementId) ||
376         !FormAutofillUtils.isFieldAutofillable(element)
377       ) {
378         continue;
379       }
381       element.previewValue = "";
383       if (HTMLInputElement.isInstance(element)) {
384         // Bug 1687679: Since profile appears to be presentation ready data, we need to utilize the "x-formatted" field
385         // that is generated when presentation ready data doesn't fit into the autofilling element.
386         // For example, autofilling expiration month into an input element will not work as expected if
387         // the month is less than 10, since the input is expected a zero-padded string.
388         // See Bug 1722941 for follow up.
389         const value = this.getFilledValueFromProfile(fieldDetail, profile);
390         if (!value) {
391           continue;
392         }
394         // For the focused input element, it will be filled with a valid value
395         // anyway.
396         // For the others, the fields should be only filled when their values are empty
397         // or their values are equal to the site prefill value
398         // or are the result of an earlier auto-fill.
399         if (
400           elementId == focusedId ||
401           !element.value ||
402           element.value == element.defaultValue ||
403           element.autofillState == FIELD_STATES.AUTO_FILLED
404         ) {
405           FormAutofillHandler.fillFieldValue(element, value);
406           this.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED);
407         }
408       } else if (HTMLSelectElement.isInstance(element)) {
409         const option = this.matchSelectOptions(fieldDetail, profile);
410         if (!option) {
411           continue;
412         }
414         // Do not change value or dispatch events if the option is already selected.
415         // Use case for multiple select is not considered here.
416         if (!option.selected) {
417           option.selected = true;
418           FormAutofillHandler.fillFieldValue(element, option.value);
419         }
420         // Autofill highlight appears regardless if value is changed or not
421         this.changeFieldState(fieldDetail, FIELD_STATES.AUTO_FILLED);
422       } else {
423         continue;
424       }
425     }
427     FormAutofillUtils.getElementByIdentifier(focusedId)?.focus({
428       preventScroll: true,
429     });
431     this.registerFormChangeHandler();
432   }
434   registerFormChangeHandler() {
435     if (this.onChangeHandler) {
436       return;
437     }
439     this.log.debug("register change handler for filled form:", this.form);
441     this.onChangeHandler = e => {
442       if (!e.isTrusted) {
443         return;
444       }
445       if (e.type == "reset") {
446         for (const fieldDetail of this.fieldDetails) {
447           const element = fieldDetail.element;
448           element.removeEventListener("input", this, { mozSystemGroup: true });
449           this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
450         }
451       }
453       // Unregister listeners once no field is in AUTO_FILLED state.
454       if (
455         this.fieldDetails.every(
456           detail => detail.element.autofillState != FIELD_STATES.AUTO_FILLED
457         )
458       ) {
459         this.form.rootElement.removeEventListener(
460           "input",
461           this.onChangeHandler,
462           {
463             mozSystemGroup: true,
464           }
465         );
466         this.form.rootElement.removeEventListener(
467           "reset",
468           this.onChangeHandler,
469           {
470             mozSystemGroup: true,
471           }
472         );
473         this.onChangeHandler = null;
474       }
475     };
477     // Handle the highlight style resetting caused by user's correction afterward.
478     this.log.debug("register change handler for filled form:", this.form);
479     this.form.rootElement.addEventListener("input", this.onChangeHandler, {
480       mozSystemGroup: true,
481     });
482     this.form.rootElement.addEventListener("reset", this.onChangeHandler, {
483       mozSystemGroup: true,
484     });
485   }
487   computeFillingValue(fieldDetail) {
488     const element = fieldDetail.element;
489     if (!element) {
490       return null;
491     }
493     let value = element.value.trim();
494     switch (fieldDetail.fieldName) {
495       case "address-level1":
496         if (HTMLSelectElement.isInstance(element)) {
497           // Don't save the record when the option value is empty *OR* there
498           // are multiple options being selected. The empty option is usually
499           // assumed to be default along with a meaningless text to users.
500           if (!value || element.selectedOptions.length != 1) {
501             // Keep the property and preserve more information for address updating
502             value = "";
503           } else {
504             const text = element.selectedOptions[0].text.trim();
505             value =
506               FormAutofillUtils.getAbbreviatedSubregionName([value, text]) ||
507               text;
508           }
509         }
510         break;
511       case "country":
512         // This is a temporary fix. Ideally we should have either case-insensitive comparison of country codes
513         // or handle this elsewhere see Bug 1889234 for more context.
514         value = value.toUpperCase();
515         break;
516       case "cc-type":
517         if (
518           HTMLSelectElement.isInstance(element) &&
519           !lazy.CreditCard.isValidNetwork(value)
520         ) {
521           // Don't save the record when the option value is empty *OR* there
522           // are multiple options being selected. The empty option is usually
523           // assumed to be default along with a meaningless text to users.
524           if (value && element.selectedOptions.length == 1) {
525             const selectedOption = element.selectedOptions[0];
526             const networkType =
527               lazy.CreditCard.getNetworkFromName(selectedOption.text) ??
528               lazy.CreditCard.getNetworkFromName(selectedOption.value);
529             if (networkType) {
530               value = networkType;
531             }
532           }
533         }
534         break;
535     }
537     return value;
538   }
540   /*
541    * Apply both address and credit card related transformers.
542    *
543    * @param {Object} profile
544    *        A profile for adjusting credit card related value.
545    * @override
546    */
547   applyTransformers(profile) {
548     this.addressTransformer(profile);
549     this.telTransformer(profile);
550     this.creditCardExpiryDateTransformer(profile);
551     this.creditCardExpMonthAndYearTransformer(profile);
552     this.creditCardNameTransformer(profile);
553     this.adaptFieldMaxLength(profile);
554   }
556   getAdaptedProfiles(originalProfiles) {
557     for (let profile of originalProfiles) {
558       this.applyTransformers(profile);
559     }
560     return originalProfiles;
561   }
563   /**
564    * Match the select option for a field if we autofill with the given profile.
565    * This function caches the matching result in the `#matchingSelectionOption`
566    * variable.
567    *
568    * @param {FieldDetail} fieldDetail
569    *        The field information of the matching element.
570    * @param {object} profile
571    *        The profile used for autofill.
572    *
573    * @returns {Option}
574    *        The matched option, or undefined if no matching option is found.
575    */
576   matchSelectOptions(fieldDetail, profile) {
577     if (!this.#matchingSelectOption) {
578       this.#matchingSelectOption = new WeakMap();
579     }
581     const { element, fieldName } = fieldDetail;
582     if (!HTMLSelectElement.isInstance(element)) {
583       return undefined;
584     }
586     const cache = this.#matchingSelectOption.get(element) || {};
587     const value = profile[fieldName];
589     let option = cache[value]?.deref();
590     if (!option) {
591       option = FormAutofillUtils.findSelectOption(element, profile, fieldName);
593       if (option) {
594         cache[value] = new WeakRef(option);
595         this.#matchingSelectOption.set(element, cache);
596       } else if (cache[value]) {
597         delete cache[value];
598         this.#matchingSelectOption.set(element, cache);
599       }
600     }
602     return option;
603   }
605   adaptFieldMaxLength(profile) {
606     for (let key in profile) {
607       let detail = this.getFieldDetailByName(key);
608       if (!detail || detail.part) {
609         continue;
610       }
612       let element = detail.element;
613       if (!element) {
614         continue;
615       }
617       let maxLength = element.maxLength;
618       if (
619         maxLength === undefined ||
620         maxLength < 0 ||
621         profile[key].toString().length <= maxLength
622       ) {
623         continue;
624       }
626       if (maxLength) {
627         switch (typeof profile[key]) {
628           case "string":
629             // If this is an expiration field and our previous
630             // adaptations haven't resulted in a string that is
631             // short enough to satisfy the field length, and the
632             // field is constrained to a length of 4 or 5, then we
633             // assume it is intended to hold an expiration of the
634             // form "MMYY" or "MM/YY".
635             if (key == "cc-exp" && (maxLength == 4 || maxLength == 5)) {
636               const month2Digits = (
637                 "0" + profile["cc-exp-month"].toString()
638               ).slice(-2);
639               const year2Digits = profile["cc-exp-year"].toString().slice(-2);
640               const separator = maxLength == 5 ? "/" : "";
641               profile[key] = `${month2Digits}${separator}${year2Digits}`;
642             } else if (key == "cc-number") {
643               // We want to show the last four digits of credit card so that
644               // the masked credit card previews correctly and appears correctly
645               // in the autocomplete menu
646               profile[key] = profile[key].substr(
647                 profile[key].length - maxLength
648               );
649             } else {
650               profile[key] = profile[key].substr(0, maxLength);
651             }
652             break;
653           case "number":
654             // There's no way to truncate a number smaller than a
655             // single digit.
656             if (maxLength < 1) {
657               maxLength = 1;
658             }
659             // The only numbers we store are expiration month/year,
660             // and if they truncate, we want the final digits, not
661             // the initial ones.
662             profile[key] = profile[key] % Math.pow(10, maxLength);
663             break;
664           default:
665         }
666       } else {
667         delete profile[key];
668         delete profile[`${key}-formatted`];
669       }
670     }
671   }
673   /**
674    * Handles credit card expiry date transformation when
675    * the expiry date exists in a cc-exp field.
676    *
677    * @param {object} profile
678    */
679   creditCardExpiryDateTransformer(profile) {
680     if (!profile["cc-exp"]) {
681       return;
682     }
684     const element = this.getFieldDetailByName("cc-exp")?.element;
685     if (!element) {
686       return;
687     }
689     function updateExpiry(_string, _month, _year) {
690       // Bug 1687681: This is a short term fix to other locales having
691       // different characters to represent year.
692       // - FR locales may use "A" to represent year.
693       // - DE locales may use "J" to represent year.
694       // - PL locales may use "R" to represent year.
695       // This approach will not scale well and should be investigated in a follow up bug.
696       const monthChars = "m";
697       const yearChars = "yy|aa|jj|rr";
698       const expiryDateFormatRegex = (firstChars, secondChars) =>
699         new RegExp(
700           "(?:\\b|^)((?:[" +
701             firstChars +
702             "]{2}){1,2})\\s*([\\-/])\\s*((?:[" +
703             secondChars +
704             "]{2}){1,2})(?:\\b|$)",
705           "i"
706         );
708       // If the month first check finds a result, where placeholder is "mm - yyyy",
709       // the result will be structured as such: ["mm - yyyy", "mm", "-", "yyyy"]
710       let result = expiryDateFormatRegex(monthChars, yearChars).exec(_string);
711       if (result) {
712         return (
713           _month.padStart(result[1].length, "0") +
714           result[2] +
715           _year.substr(-1 * result[3].length)
716         );
717       }
719       // If the year first check finds a result, where placeholder is "yyyy mm",
720       // the result will be structured as such: ["yyyy mm", "yyyy", " ", "mm"]
721       result = expiryDateFormatRegex(yearChars, monthChars).exec(_string);
722       if (result) {
723         return (
724           _year.substr(-1 * result[1].length) +
725           result[2] +
726           _month.padStart(result[3].length, "0")
727         );
728       }
729       return null;
730     }
732     let newExpiryString = null;
733     const month = profile["cc-exp-month"].toString();
734     const year = profile["cc-exp-year"].toString();
735     if (element.localName == "input") {
736       // Use the placeholder or label to determine the expiry string format.
737       const possibleExpiryStrings = [];
738       if (element.placeholder) {
739         possibleExpiryStrings.push(element.placeholder);
740       }
741       const labels = lazy.LabelUtils.findLabelElements(element);
742       if (labels) {
743         // Not consider multiple lable for now.
744         possibleExpiryStrings.push(element.labels[0]?.textContent);
745       }
746       if (element.previousElementSibling?.localName == "label") {
747         possibleExpiryStrings.push(element.previousElementSibling.textContent);
748       }
750       possibleExpiryStrings.some(string => {
751         newExpiryString = updateExpiry(string, month, year);
752         return !!newExpiryString;
753       });
754     }
756     // Bug 1688576: Change YYYY-MM to MM/YYYY since MM/YYYY is the
757     // preferred presentation format for credit card expiry dates.
758     profile["cc-exp"] = newExpiryString ?? `${month.padStart(2, "0")}/${year}`;
759   }
761   /**
762    * Handles credit card expiry date transformation when the expiry date exists in
763    * the separate cc-exp-month and cc-exp-year fields
764    *
765    * @param {object} profile
766    */
767   creditCardExpMonthAndYearTransformer(profile) {
768     const getInputElementByField = (field, self) => {
769       if (!field) {
770         return null;
771       }
772       const detail = self.getFieldDetailByName(field);
773       if (!detail) {
774         return null;
775       }
776       const element = detail.element;
777       return element.localName === "input" ? element : null;
778     };
779     const month = getInputElementByField("cc-exp-month", this);
780     if (month) {
781       // Transform the expiry month to MM since this is a common format needed for filling.
782       profile["cc-exp-month-formatted"] = profile["cc-exp-month"]
783         ?.toString()
784         .padStart(2, "0");
785     }
786     const year = getInputElementByField("cc-exp-year", this);
787     // If the expiration year element is an input,
788     // then we examine any placeholder to see if we should format the expiration year
789     // as a zero padded string in order to autofill correctly.
790     if (year) {
791       const placeholder = year.placeholder;
793       // Checks for 'YY'|'AA'|'JJ'|'RR' placeholder and converts the year to a two digit string using the last two digits.
794       const result = /\b(yy|aa|jj|rr)\b/i.test(placeholder);
795       if (result) {
796         profile["cc-exp-year-formatted"] = profile["cc-exp-year"]
797           ?.toString()
798           .substring(2);
799       }
800     }
801   }
803   /**
804    * Handles credit card name transformation when the name exists in
805    * the separate cc-given-name, cc-middle-name, and cc-family name fields
806    *
807    * @param {object} profile
808    */
809   creditCardNameTransformer(profile) {
810     const name = profile["cc-name"];
811     if (!name) {
812       return;
813     }
815     const given = this.getFieldDetailByName("cc-given-name");
816     const middle = this.getFieldDetailByName("cc-middle-name");
817     const family = this.getFieldDetailByName("cc-family-name");
818     if (given || middle || family) {
819       const nameParts = lazy.FormAutofillNameUtils.splitName(name);
820       if (given && nameParts.given) {
821         profile["cc-given-name"] = nameParts.given;
822       }
823       if (middle && nameParts.middle) {
824         profile["cc-middle-name"] = nameParts.middle;
825       }
826       if (family && nameParts.family) {
827         profile["cc-family-name"] = nameParts.family;
828       }
829     }
830   }
832   addressTransformer(profile) {
833     if (profile["street-address"]) {
834       // "-moz-street-address-one-line" is used by the labels in
835       // ProfileAutoCompleteResult.
836       profile["-moz-street-address-one-line"] =
837         FormAutofillUtils.toOneLineAddress(profile["street-address"]);
838       let streetAddressDetail = this.getFieldDetailByName("street-address");
839       if (
840         streetAddressDetail &&
841         HTMLInputElement.isInstance(streetAddressDetail.element)
842       ) {
843         profile["street-address"] = profile["-moz-street-address-one-line"];
844       }
846       let waitForConcat = [];
847       for (let f of ["address-line3", "address-line2", "address-line1"]) {
848         waitForConcat.unshift(profile[f]);
849         if (this.getFieldDetailByName(f)) {
850           if (waitForConcat.length > 1) {
851             profile[f] = FormAutofillUtils.toOneLineAddress(waitForConcat);
852           }
853           waitForConcat = [];
854         }
855       }
856     }
858     // If a house number field exists, split the address up into house number
859     // and street name.
860     if (this.getFieldDetailByName("address-housenumber")) {
861       let address = lazy.AddressParser.parseStreetAddress(
862         profile["street-address"]
863       );
864       if (address) {
865         profile["address-housenumber"] = address.street_number;
866         let field = this.getFieldDetailByName("address-line1")
867           ? "address-line1"
868           : "street-address";
869         profile[field] = address.street_name;
870       }
871     }
872   }
874   /**
875    * Replace tel with tel-national if tel violates the input element's
876    * restriction.
877    *
878    * @param {object} profile
879    *        A profile to be converted.
880    */
881   telTransformer(profile) {
882     if (!profile.tel || !profile["tel-national"]) {
883       return;
884     }
886     let detail = this.getFieldDetailByName("tel");
887     if (!detail) {
888       return;
889     }
891     let element = detail.element;
892     let _pattern;
893     let testPattern = str => {
894       if (!_pattern) {
895         // The pattern has to match the entire value.
896         _pattern = new RegExp("^(?:" + element.pattern + ")$", "u");
897       }
898       return _pattern.test(str);
899     };
900     if (element.pattern) {
901       if (testPattern(profile.tel)) {
902         return;
903       }
904     } else if (element.maxLength) {
905       if (
906         detail.reason == "autocomplete" &&
907         profile.tel.length <= element.maxLength
908       ) {
909         return;
910       }
911     }
913     if (detail.reason != "autocomplete") {
914       // Since we only target people living in US and using en-US websites in
915       // MVP, it makes more sense to fill `tel-national` instead of `tel`
916       // if the field is identified by heuristics and no other clues to
917       // determine which one is better.
918       // TODO: [Bug 1407545] This should be improved once more countries are
919       // supported.
920       profile.tel = profile["tel-national"];
921     } else if (element.pattern) {
922       if (testPattern(profile["tel-national"])) {
923         profile.tel = profile["tel-national"];
924       }
925     } else if (element.maxLength) {
926       if (profile["tel-national"].length <= element.maxLength) {
927         profile.tel = profile["tel-national"];
928       }
929     }
930   }
932   /**
933    *
934    * @param {object} fieldDetail A fieldDetail of the related element.
935    * @param {object} profile The profile to fill.
936    * @returns {string} The value to fill for the given field.
937    */
938   getFilledValueFromProfile(fieldDetail, profile) {
939     let value =
940       profile[`${fieldDetail.fieldName}-formatted`] ||
941       profile[fieldDetail.fieldName];
943     if (fieldDetail.fieldName == "cc-number" && fieldDetail.part != null) {
944       const part = fieldDetail.part;
945       return value.slice((part - 1) * 4, part * 4);
946     }
947     return value;
948   }
949   /**
950    * Fills the provided element with the specified value.
951    *
952    * @param {HTMLInputElement| HTMLSelectElement} element - The form field element to be filled.
953    * @param {string} value - The value to be filled into the form field.
954    */
955   static fillFieldValue(element, value) {
956     if (FormAutofillUtils.focusOnAutofill) {
957       element.focus({ preventScroll: true });
958     }
959     if (HTMLInputElement.isInstance(element)) {
960       element.setUserInput(value);
961     } else if (HTMLSelectElement.isInstance(element)) {
962       // Set the value of the select element so that web event handlers can react accordingly
963       element.value = value;
964       element.dispatchEvent(
965         new element.ownerGlobal.Event("input", { bubbles: true })
966       );
967       element.dispatchEvent(
968         new element.ownerGlobal.Event("change", { bubbles: true })
969       );
970     }
971   }
973   clearPreviewedFields(elementIds) {
974     for (const elementId of elementIds) {
975       const fieldDetail = this.getFieldDetailByElementId(elementId);
976       const element = fieldDetail?.element;
977       if (!element) {
978         this.log.warn(fieldDetail.fieldName, "is unreachable");
979         continue;
980       }
982       element.previewValue = "";
983       if (element.autofillState == FIELD_STATES.AUTO_FILLED) {
984         continue;
985       }
986       this.changeFieldState(fieldDetail, FIELD_STATES.NORMAL);
987     }
988   }
990   clearFilledFields(focusedId, elementIds) {
991     const fieldDetails = elementIds.map(id =>
992       this.getFieldDetailByElementId(id)
993     );
994     for (const fieldDetail of fieldDetails) {
995       const element = fieldDetail?.element;
996       if (!element) {
997         this.log.warn(fieldDetail?.fieldName, "is unreachable");
998         continue;
999       }
1001       if (element.autofillState == FIELD_STATES.AUTO_FILLED) {
1002         let value = "";
1003         if (HTMLSelectElement.isInstance(element)) {
1004           if (!element.options.length) {
1005             continue;
1006           }
1007           // Resets a <select> element to its selected option or the first
1008           // option if there is none selected.
1009           const selected = [...element.options].find(option =>
1010             option.hasAttribute("selected")
1011           );
1012           value = selected ? selected.value : element.options[0].value;
1013         }
1014         FormAutofillHandler.fillFieldValue(element, value);
1015       }
1016     }
1018     let focusedElement = FormAutofillUtils.getElementByIdentifier(focusedId);
1019     if (FormAutofillUtils.focusOnAutofill && focusedElement) {
1020       focusedElement.focus({ preventScroll: true });
1021     }
1022   }
1024   /**
1025    * Return the record that is keyed by element id and value is the normalized value
1026    * done by computeFillingValue
1027    *
1028    * @returns {object} An object keyed by element id, and the value is
1029    *                   an object that includes the following properties:
1030    * filledState: The autofill state of the element
1031    * filledvalue: The value of the element
1032    */
1033   collectFormFilledData() {
1034     const filledData = new Map();
1036     for (const fieldDetail of this.fieldDetails) {
1037       const element = fieldDetail.element;
1038       filledData.set(fieldDetail.elementId, {
1039         filledState: element.autofillState,
1040         filledValue: this.computeFillingValue(fieldDetail),
1041       });
1042     }
1043     return filledData;
1044   }
1046   isFieldAutofillable(fieldDetail, profile) {
1047     if (HTMLInputElement.isInstance(fieldDetail.element)) {
1048       return !!profile[fieldDetail.fieldName];
1049     }
1050     return !!this.matchSelectOptions(fieldDetail, profile);
1051   }