Bug 1300989 - Part 1: Implement the profile filling feature when a user selects one.; r?MattN draft
authorSean Lee <selee@mozilla.com>
Sat, 07 Jan 2017 09:22:19 +0800
changeset 483473 629a34ec404f9c426857172644b4ae8003ac1b70
parent 483472 b0d69c6dc9cc37cfaec94f030eac95721aceaae9
child 483474 477c984b3495301df0a9e49bb6b00d16a16c90c6
push id45322
push userbmo:selee@mozilla.com
push dateTue, 14 Feb 2017 10:36:08 +0000
reviewersMattN
bugs1300989
milestone54.0a1
Bug 1300989 - Part 1: Implement the profile filling feature when a user selects one.; r?MattN MozReview-Commit-ID: 5oMgvdO5RD1
browser/extensions/formautofill/FormAutofillContent.jsm
browser/extensions/formautofill/FormAutofillHandler.jsm
browser/extensions/formautofill/FormAutofillParent.jsm
browser/extensions/formautofill/FormAutofillUtils.jsm
browser/extensions/formautofill/ProfileAutoCompleteResult.jsm
--- a/browser/extensions/formautofill/FormAutofillContent.jsm
+++ b/browser/extensions/formautofill/FormAutofillContent.jsm
@@ -82,111 +82,159 @@ AutofillProfileAutoCompleteSearch.protot
    * or asynchronously) of the result
    *
    * @param {string} searchString the string to search for
    * @param {string} searchParam
    * @param {Object} previousResult a previous result to use for faster searchinig
    * @param {Object} listener the listener to notify when the search is complete
    */
   startSearch(searchString, searchParam, previousResult, listener) {
+    let focusedInput = formFillController.focusedInput;
     this.forceStop = false;
-    let info = this.getInputDetails();
+    let info = FormAutofillContent.getInputDetails(focusedInput);
 
-    this.getProfiles({info, searchString}).then((profiles) => {
+    this._getProfiles({info, searchString}).then((profiles) => {
       if (this.forceStop) {
         return;
       }
 
-      // TODO: Set formInfo for ProfileAutoCompleteResult
-      // let formInfo = this.getFormDetails();
-      let result = new ProfileAutoCompleteResult(searchString, info, profiles, {});
+      let allFieldNames = FormAutofillContent.getAllFieldNames(focusedInput);
+      let result = new ProfileAutoCompleteResult(searchString,
+                                                 info.fieldName,
+                                                 allFieldNames,
+                                                 profiles,
+                                                 {});
 
       listener.onSearchResult(this, result);
+      ProfileAutocomplete.setProfileAutoCompleteResult(result);
     });
   },
 
   /**
    * Stops an asynchronous search that is in progress
    */
   stopSearch() {
+    ProfileAutocomplete.setProfileAutoCompleteResult(null);
     this.forceStop = true;
   },
 
   /**
    * Get the profile data from parent process for AutoComplete result.
    *
    * @private
    * @param  {Object} data
    *         Parameters for querying the corresponding result.
    * @param  {string} data.searchString
    *         The typed string for filtering out the matched profile.
    * @param  {string} data.info
    *         The input autocomplete property's information.
    * @returns {Promise}
    *          Promise that resolves when profiles returned from parent process.
    */
-  getProfiles(data) {
+  _getProfiles(data) {
     return new Promise((resolve) => {
       Services.cpmm.addMessageListener("FormAutofill:Profiles", function getResult(result) {
         Services.cpmm.removeMessageListener("FormAutofill:Profiles", getResult);
         resolve(result.data);
       });
 
       Services.cpmm.sendAsyncMessage("FormAutofill:GetProfiles", data);
     });
   },
-
-
-  /**
-   * Get the input's information from FormAutofillContent's cache.
-   *
-   * @returns {Object}
-   *          Target input's information that cached in FormAutofillContent.
-   */
-  getInputDetails() {
-    // TODO: Maybe we'll need to wait for cache ready if detail is empty.
-    return FormAutofillContent.getInputDetails(formFillController.focusedInput);
-  },
-
-  /**
-   * Get the form's information from FormAutofillContent's cache.
-   *
-   * @returns {Array<Object>}
-   *          Array of the inputs' information for the target form.
-   */
-  getFormDetails() {
-    // TODO: Maybe we'll need to wait for cache ready if details is empty.
-    return FormAutofillContent.getFormDetails(formFillController.focusedInput);
-  },
 };
 
 this.NSGetFactory = XPCOMUtils.generateNSGetFactory([AutofillProfileAutoCompleteSearch]);
 
 let ProfileAutocomplete = {
+  _lastAutoCompleteResult: null,
   _registered: false,
   _factory: null,
 
   ensureRegistered() {
     if (this._registered) {
       return;
     }
 
     this._factory = new AutocompleteFactory();
     this._factory.register(AutofillProfileAutoCompleteSearch);
     this._registered = true;
+
+    Services.obs.addObserver(this, "autocomplete-will-enter-text", false);
   },
 
   ensureUnregistered() {
     if (!this._registered) {
       return;
     }
 
     this._factory.unregister();
     this._factory = null;
     this._registered = false;
+
+    Services.obs.removeObserver(this, "autocomplete-will-enter-text");
+  },
+
+  setProfileAutoCompleteResult(result) {
+    this._lastAutoCompleteResult = result;
+  },
+
+  observe(subject, topic, data) {
+    switch (topic) {
+      case "autocomplete-will-enter-text": {
+        if (!formFillController.focusedInput) {
+          break;
+        }
+        this._fillFromAutocompleteRow(formFillController.focusedInput);
+        break;
+      }
+    }
+  },
+
+  _frameMMFromWindow(contentWindow) {
+    return contentWindow.QueryInterface(Ci.nsIInterfaceRequestor)
+                         .getInterface(Ci.nsIDocShell)
+                         .QueryInterface(Ci.nsIInterfaceRequestor)
+                         .getInterface(Ci.nsIContentFrameMessageManager);
+  },
+
+  _fillFromAutocompleteRow(focusedInput) {
+    let formDetails = FormAutofillContent.getFormDetails(focusedInput);
+    if (!formDetails) {
+      // The observer notification was for a different frame.
+      return;
+    }
+
+    let mm = this._frameMMFromWindow(focusedInput.ownerDocument.defaultView);
+    let selectedIndexResult = mm.sendSyncMessage("FormAutoComplete:GetSelectedIndex", {});
+    if (selectedIndexResult.length != 1 || !Number.isInteger(selectedIndexResult[0])) {
+      throw new Error("Invalid autocomplete selectedIndex");
+    }
+    let selectedIndex = selectedIndexResult[0];
+
+    if (selectedIndex == -1 ||
+        !this._lastAutoCompleteResult ||
+        this._lastAutoCompleteResult.getStyleAt(selectedIndex) != "autofill-profile") {
+      return;
+    }
+
+    let profile = JSON.parse(this._lastAutoCompleteResult.getCommentAt(selectedIndex));
+
+    // TODO: FormAutofillHandler.autofillFormFields will be used for filling
+    // fields logic eventually.
+    for (let inputInfo of formDetails) {
+      // Skip filling the value of focused input which is filled in
+      // FormFillController.
+      if (inputInfo.element === focusedInput) {
+        continue;
+      }
+      let value = profile[inputInfo.fieldName];
+      if (value) {
+        inputInfo.element.setUserInput(value);
+      }
+    }
   },
 };
 
 /**
  * Handles content's interactions for the process.
  *
  * NOTE: Declares it by "var" to make it accessible in unit tests.
  */
@@ -211,17 +259,17 @@ var FormAutofillContent = {
    * @returns {Object|null}
    *          Return target input's information that cloned from content cache
    *          (or return null if the information is not found in the cache).
    */
   getInputDetails(element) {
     for (let formDetails of this._formsDetails) {
       for (let detail of formDetails) {
         if (element == detail.element) {
-          return this._serializeInfo(detail);
+          return detail;
         }
       }
     }
     return null;
   },
 
   /**
    * Get the form's information from cache which is created after page identified.
@@ -230,34 +278,25 @@ var FormAutofillContent = {
    * @returns {Array<Object>|null}
    *          Return target form's information that cloned from content cache
    *          (or return null if the information is not found in the cache).
    *
    */
   getFormDetails(element) {
     for (let formDetails of this._formsDetails) {
       if (formDetails.some((detail) => detail.element == element)) {
-        return formDetails.map((detail) => this._serializeInfo(detail));
+        return formDetails;
       }
     }
     return null;
   },
 
-  /**
-   * Create a clone the information object without element reference.
-   *
-   * @param {Object} detail Profile autofill information for specific input.
-   * @returns {Object}
-   *          Return a copy of cached information object without element reference
-   *          since it's not needed for creating result.
-   */
-  _serializeInfo(detail) {
-    let info = Object.assign({}, detail);
-    delete info.element;
-    return info;
+  getAllFieldNames(element) {
+    let formDetails = this.getFormDetails(element);
+    return formDetails.map(record => record.fieldName);
   },
 
   _identifyAutofillFields(doc) {
     let forms = [];
     this._formsDetails = [];
 
     // Collects root forms from inputs.
     for (let field of doc.getElementsByTagName("input")) {
--- a/browser/extensions/formautofill/FormAutofillHandler.jsm
+++ b/browser/extensions/formautofill/FormAutofillHandler.jsm
@@ -106,16 +106,19 @@ FormAutofillHandler.prototype = {
    *          fieldName: Value originally provided to the user interface.
    *          value: String with which the field should be updated.
    *          index: Index to match the input in fieldDetails
    *        }],
    *        }
    */
   autofillFormFields(autofillResult) {
     for (let field of autofillResult) {
+      // TODO: Skip filling the value of focused input which is filled in
+      // FormFillController.
+
       // Get the field details, if it was processed by the user interface.
       let fieldDetail = this.fieldDetails[field.index];
 
       // Avoid the invalid value set
       if (!fieldDetail || !field.value) {
         continue;
       }
 
--- a/browser/extensions/formautofill/FormAutofillParent.jsm
+++ b/browser/extensions/formautofill/FormAutofillParent.jsm
@@ -107,20 +107,18 @@ FormAutofillParent.prototype = {
   },
 
   /**
    * Add/remove message listener and broadcast the status to frames while the
    * form autofill status changed.
    */
   _onStatusChanged() {
     if (this._enabled) {
-      Services.ppmm.addMessageListener("FormAutofill:PopulateFieldValues", this);
       Services.ppmm.addMessageListener("FormAutofill:GetProfiles", this);
     } else {
-      Services.ppmm.removeMessageListener("FormAutofill:PopulateFieldValues", this);
       Services.ppmm.removeMessageListener("FormAutofill:GetProfiles", this);
     }
 
     Services.mm.broadcastAsyncMessage("FormAutofill:enabledStatus", this._enabled);
   },
 
   /**
    * Query pref (and storage) status to determine the overall status for
@@ -136,19 +134,16 @@ FormAutofillParent.prototype = {
    * Handles the message coming from FormAutofillContent.
    *
    * @param   {string} message.name The name of the message.
    * @param   {object} message.data The data of the message.
    * @param   {nsIFrameMessageManager} message.target Caller's message manager.
    */
   receiveMessage({name, data, target}) {
     switch (name) {
-      case "FormAutofill:PopulateFieldValues":
-        this._populateFieldValues(data, target);
-        break;
       case "FormAutofill:GetProfiles":
         this._getProfiles(data, target);
         break;
       case "FormAutofill:getEnabledStatus":
         Services.ppmm.broadcastAsyncMessage("FormAutofill:enabledStatus",
                                             this._enabled);
         break;
     }
@@ -171,41 +166,22 @@ FormAutofillParent.prototype = {
    * @private
    */
   _uninit() {
     if (this._profileStore) {
       this._profileStore._saveImmediately();
       this._profileStore = null;
     }
 
-    Services.ppmm.removeMessageListener("FormAutofill:PopulateFieldValues", this);
     Services.ppmm.removeMessageListener("FormAutofill:GetProfiles", this);
     Services.obs.removeObserver(this, "advanced-pane-loaded");
     Services.prefs.removeObserver(ENABLED_PREF, this);
   },
 
   /**
-   * Populates the field values and notifies content to fill in. Exception will
-   * be thrown if there's no matching profile.
-   *
-   * @private
-   * @param  {string} data.guid
-   *         Indicates which profile to populate
-   * @param  {Fields} data.fields
-   *         The "fields" array collected from content.
-   * @param  {nsIFrameMessageManager} target
-   *         Content's message manager.
-   */
-  _populateFieldValues({guid, fields}, target) {
-    this._profileStore.notifyUsed(guid);
-    this._fillInFields(this._profileStore.get(guid), fields);
-    target.sendAsyncMessage("FormAutofill:fillForm", {fields});
-  },
-
-  /**
    * Get the profile data from profile store and return profiles back to content process.
    *
    * @private
    * @param  {string} data.searchString
    *         The typed string for filtering out the matched profile.
    * @param  {string} data.info
    *         The input autocomplete property's information.
    * @param  {nsIFrameMessageManager} target
@@ -217,47 +193,11 @@ FormAutofillParent.prototype = {
     if (info && info.fieldName) {
       profiles = this._profileStore.getByFilter({searchString, info});
     } else {
       profiles = this._profileStore.getAll();
     }
 
     target.sendAsyncMessage("FormAutofill:Profiles", profiles);
   },
-
-  /**
-   * Get the corresponding value from the specified profile according to a valid
-   * @autocomplete field name.
-   *
-   * Note that the field name doesn't need to match the property name defined in
-   * Profile object. This method can transform the raw data to fulfill it. (e.g.
-   * inputting "country-name" as "fieldName" will get a full name transformed
-   * from the country code that is recorded in "country" field.)
-   *
-   * @private
-   * @param   {Profile} profile   The specified profile.
-   * @param   {string}  fieldName A valid @autocomplete field name.
-   * @returns {string}  The corresponding value. Returns "undefined" if there's
-   *                    no matching field.
-   */
-  _getDataByFieldName(profile, fieldName) {
-    // TODO: Transform the raw profile data to fulfill "fieldName" here.
-    return profile[fieldName];
-  },
-
-  /**
-   * Fills in the "fields" array by the specified profile.
-   *
-   * @private
-   * @param   {Profile} profile The specified profile to fill in.
-   * @param   {Fields}  fields  The "fields" array collected from content.
-   */
-  _fillInFields(profile, fields) {
-    for (let field of fields) {
-      let value = this._getDataByFieldName(profile, field.fieldName);
-      if (value !== undefined) {
-        field.value = value;
-      }
-    }
-  },
 };
 
 this.EXPORTED_SYMBOLS = ["FormAutofillParent"];
new file mode 100644
--- /dev/null
+++ b/browser/extensions/formautofill/FormAutofillUtils.jsm
@@ -0,0 +1,22 @@
+/* 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/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["FormAutofillUtils"];
+
+this.FormAutofillUtils = {
+  generateFullName(firstName, lastName, middleName) {
+    // TODO: The implementation should depend on the L10N spec, but a simplified
+    // rule is used here.
+    let fullName = firstName;
+    if (middleName) {
+      fullName += " " + middleName;
+    }
+    if (lastName) {
+      fullName += " " + lastName;
+    }
+    return fullName;
+  },
+};
--- a/browser/extensions/formautofill/ProfileAutoCompleteResult.jsm
+++ b/browser/extensions/formautofill/ProfileAutoCompleteResult.jsm
@@ -5,89 +5,159 @@
 "use strict";
 
 this.EXPORTED_SYMBOLS = ["ProfileAutoCompleteResult"];
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
+XPCOMUtils.defineLazyModuleGetter(this, "FormAutofillUtils",
+                                  "resource://formautofill/FormAutofillUtils.jsm");
+
 this.ProfileAutoCompleteResult = function(searchString,
-                                           fieldName,
-                                           matchingProfiles,
-                                           {resultCode = null}) {
+                                          focusedFieldName,
+                                          allFieldNames,
+                                          matchingProfiles,
+                                          {resultCode = null}) {
   this.searchString = searchString;
-  this._fieldName = fieldName;
+  this._focusedFieldName = focusedFieldName;
+  this._allFieldNames = allFieldNames;
   this._matchingProfiles = matchingProfiles;
 
   if (resultCode) {
     this.searchResult = resultCode;
   } else if (matchingProfiles.length > 0) {
     this.searchResult = Ci.nsIAutoCompleteResult.RESULT_SUCCESS;
   } else {
     this.searchResult = Ci.nsIAutoCompleteResult.RESULT_NOMATCH;
   }
+
+  this._popupLabels = this._generateLabels(this._focusedFieldName,
+                                           this._allFieldNames,
+                                           this._matchingProfiles);
 };
 
 ProfileAutoCompleteResult.prototype = {
 
   // The user's query string
   searchString: "",
 
   // The default item that should be entered if none is selected
   defaultIndex: 0,
 
   // The reason the search failed
   errorDescription: "",
 
   // The result code of this result object.
   searchResult: null,
 
-  // The autocomplete attribute of the focused input field
-  _fieldName: "",
+  // The field name of the focused input.
+  _focusedFieldName: "",
+
+  // All field names in the form which contains the focused input.
+  _allFieldNames: null,
 
   // The matching profiles contains the information for filling forms.
   _matchingProfiles: null,
 
+  // An array of primary and secondary labels for each profiles.
+  _popupLabels: null,
+
   /**
    * @returns {number} The number of results
    */
   get matchCount() {
     return this._matchingProfiles.length;
   },
 
   _checkIndexBounds(index) {
     if (index < 0 || index >= this._matchingProfiles.length) {
       throw Components.Exception("Index out of range.", Cr.NS_ERROR_ILLEGAL_VALUE);
     }
   },
 
   /**
+   * Get the secondary label based on the focused field name and related field names
+   * in the same form.
+   * @param   {string} focusedFieldName The field name of the focused input
+   * @param   {Array<Object>} allFieldNames The field names in the same section
+   * @param   {object} profile The profile providing the labels to show.
+   * @returns {string} The secondary label
+   */
+  _getSecondaryLabel(focusedFieldName, allFieldNames, profile) {
+    /* TODO: Since "name" is a special case here, so the secondary "name" label
+       will be refined when the handling rule for "name" is ready.
+    */
+    const possibleNameFields = ["given-name", "additional-name", "family-name"];
+    focusedFieldName = possibleNameFields.includes(focusedFieldName) ?
+                       "name" : focusedFieldName;
+    if (!profile.name) {
+      profile.name = FormAutofillUtils.generateFullName(profile["given-name"],
+                                                        profile["family-name"],
+                                                        profile["additional-name"]);
+    }
+
+    const secondaryLabelOrder = [
+      "street-address",  // Street address
+      "name",            // Full name if needed
+      "address-level2",  // City/Town
+      "organization",    // Company or organization name
+      "address-level1",  // Province/State (Standardized code if possible)
+      "country",         // Country
+      "postal-code",     // Postal code
+      "tel",             // Phone number
+      "email",           // Email address
+    ];
+
+    for (const currentFieldName of secondaryLabelOrder) {
+      if (focusedFieldName != currentFieldName &&
+          allFieldNames.includes(currentFieldName) &&
+          profile[currentFieldName]) {
+        return profile[currentFieldName];
+      }
+    }
+
+    return ""; // Nothing matched.
+  },
+
+  _generateLabels(focusedFieldName, allFieldNames, profiles) {
+    return profiles.map(profile => {
+      return {
+        primary: profile[focusedFieldName],
+        secondary: this._getSecondaryLabel(focusedFieldName,
+                                           allFieldNames,
+                                           profile),
+      };
+    });
+  },
+
+  /**
    * Retrieves a result
    * @param   {number} index The index of the result requested
    * @returns {string} The result at the specified index
    */
   getValueAt(index) {
     this._checkIndexBounds(index);
-    return this._matchingProfiles[index].guid;
+    return this._popupLabels[index].primary;
   },
 
   getLabelAt(index) {
     this._checkIndexBounds(index);
-    return this._matchingProfiles[index].organization;
+    return JSON.stringify(this._popupLabels[index]);
   },
 
   /**
    * Retrieves a comment (metadata instance)
    * @param   {number} index The index of the comment requested
    * @returns {string} The comment at the specified index
    */
   getCommentAt(index) {
     this._checkIndexBounds(index);
-    return this._matchingProfiles[index].streetAddress;
+    return JSON.stringify(this._matchingProfiles[index]);
   },
 
   /**
    * Retrieves a style hint specific to a particular index.
    * @param   {number} index The index of the style hint requested
    * @returns {string} The style hint at the specified index
    */
   getStyleAt(index) {