Bug 1365544 - Handle filling inexact matches on address-level1 select fields. r=lchang
authorScott Wu <scottcwwu@gmail.com>
Tue, 16 May 2017 16:53:01 +0800
changeset 365668 a3ca1ad0db291da73564c3e266c212f2c99804ad
parent 365667 7e80bdb901d69fcae3594d8fc7de04da238907e0
child 365669 3ac80dbdf4c26d46e11b6d4e6b58daa5ecaf9981
push id91809
push usercbook@mozilla.com
push dateFri, 23 Jun 2017 09:44:41 +0000
treeherdermozilla-inbound@ab1d1b0135fe [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerslchang
bugs1365544
milestone56.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1365544 - Handle filling inexact matches on address-level1 select fields. r=lchang MozReview-Commit-ID: 21K5mC2tYQn
browser/extensions/formautofill/FormAutofillHandler.jsm
browser/extensions/formautofill/FormAutofillNameUtils.jsm
browser/extensions/formautofill/FormAutofillUtils.jsm
browser/extensions/formautofill/content/addressReferences.js
browser/extensions/formautofill/test/mochitest/test_basic_autocomplete_form.html
browser/extensions/formautofill/test/unit/test_autofillFormFields.js
--- a/browser/extensions/formautofill/FormAutofillHandler.jsm
+++ b/browser/extensions/formautofill/FormAutofillHandler.jsm
@@ -123,30 +123,29 @@ FormAutofillHandler.prototype = {
 
       let value = profile[fieldDetail.fieldName];
       if (element instanceof Ci.nsIDOMHTMLInputElement && !element.value && value) {
         if (element !== focusedInput) {
           element.setUserInput(value);
         }
         this.changeFieldState(fieldDetail, "AUTO_FILLED");
       } else if (element instanceof Ci.nsIDOMHTMLSelectElement) {
-        for (let option of element.options) {
-          if (value === option.textContent || value === option.value) {
-            // Do not change value if the option is already selected.
-            // Use case for multiple select is not considered here.
-            if (option.selected) {
-              break;
-            }
-            option.selected = true;
-            element.dispatchEvent(new element.ownerGlobal.UIEvent("input", {bubbles: true}));
-            element.dispatchEvent(new element.ownerGlobal.Event("change", {bubbles: true}));
-            this.changeFieldState(fieldDetail, "AUTO_FILLED");
-            break;
-          }
+        let option = FormAutofillUtils.findSelectOption(element, profile, fieldDetail.fieldName);
+        if (!option) {
+          continue;
         }
+        // Do not change value or dispatch events if the option is already selected.
+        // Use case for multiple select is not considered here.
+        if (!option.selected) {
+          option.selected = true;
+          element.dispatchEvent(new element.ownerGlobal.UIEvent("input", {bubbles: true}));
+          element.dispatchEvent(new element.ownerGlobal.Event("change", {bubbles: true}));
+        }
+        // Autofill highlight appears regardless if value is changed or not
+        this.changeFieldState(fieldDetail, "AUTO_FILLED");
       }
 
       // Unlike using setUserInput directly, FormFillController dispatches an
       // asynchronous "DOMAutoComplete" event with an "input" event follows right
       // after. So, we need to suppress the first "input" event fired off from
       // focused input to make sure the latter change handler won't be affected
       // by auto filling.
       if (element === focusedInput) {
@@ -205,23 +204,35 @@ FormAutofillHandler.prototype = {
    */
   previewFormFields(profile) {
     log.debug("preview profile in autofillFormFields:", profile);
 
     for (let fieldDetail of this.fieldDetails) {
       let element = fieldDetail.elementWeakRef.get();
       let value = profile[fieldDetail.fieldName] || "";
 
-      // Skip the field that is null or already has text entered
-      if (!element || element.value) {
+      // Skip the field that is null
+      if (!element) {
         continue;
       }
 
-      element.previewValue = value;
-      this.changeFieldState(fieldDetail, value ? "PREVIEW" : "NORMAL");
+      if (element instanceof Ci.nsIDOMHTMLSelectElement) {
+        // Unlike text input, select element is always previewed even if
+        // the option is already selected.
+        let option = FormAutofillUtils.findSelectOption(element, profile, fieldDetail.fieldName);
+        element.previewValue = option ? option.text : "";
+        this.changeFieldState(fieldDetail, option ? "PREVIEW" : "NORMAL");
+      } else {
+        // Skip the field if it already has text entered
+        if (element.value) {
+          continue;
+        }
+        element.previewValue = value;
+        this.changeFieldState(fieldDetail, value ? "PREVIEW" : "NORMAL");
+      }
     }
   },
 
   /**
    * Clear preview text and background highlight of all fields.
    */
   clearPreviewedFormFields() {
     log.debug("clear previewed fields in:", this.form);
--- a/browser/extensions/formautofill/FormAutofillNameUtils.jsm
+++ b/browser/extensions/formautofill/FormAutofillNameUtils.jsm
@@ -8,16 +8,18 @@ const {classes: Cc, interfaces: Ci, util
 
 // Cu.import loads jsm files based on ISO-Latin-1 for now (see bug 530257).
 // However, the references about name parts include multi-byte characters.
 // Thus, we use |loadSubScript| to load the references instead.
 const NAME_REFERENCES = "chrome://formautofill/content/nameReferences.js";
 
 this.EXPORTED_SYMBOLS = ["FormAutofillNameUtils"];
 
+Cu.import("resource://formautofill/FormAutofillUtils.jsm");
+
 // FormAutofillNameUtils is initially translated from
 // https://cs.chromium.org/chromium/src/components/autofill/core/browser/autofill_data_util.cc?rcl=b861deff77abecff11ae6a9f6946e9cc844b9817
 var FormAutofillNameUtils = {
   // Will be loaded from NAME_REFERENCES.
   NAME_PREFIXES: [],
   NAME_SUFFIXES: [],
   FAMILY_NAME_PREFIXES: [],
   COMMON_CJK_MULTI_CHAR_SURNAMES: [],
@@ -199,20 +201,17 @@ var FormAutofillNameUtils = {
 
     return nameParts;
   },
 
   init() {
     if (this._dataLoaded) {
       return;
     }
-    let sandbox = {};
-    let scriptLoader = Cc["@mozilla.org/moz/jssubscript-loader;1"]
-                         .getService(Ci.mozIJSSubScriptLoader);
-    scriptLoader.loadSubScript(NAME_REFERENCES, sandbox, "utf-8");
+    let sandbox = FormAutofillUtils.loadDataFromScript(NAME_REFERENCES);
     Object.assign(this, sandbox.nameReferences);
     this._dataLoaded = true;
 
     this.reCJK = new RegExp("[" + this.CJK_RANGE.join("") + "]", "u");
   },
 
   splitName(name) {
     let nameTokens = name.trim().split(/[ ,\u3000\u30FB\u00B7]+/);
--- a/browser/extensions/formautofill/FormAutofillUtils.jsm
+++ b/browser/extensions/formautofill/FormAutofillUtils.jsm
@@ -3,16 +3,18 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 this.EXPORTED_SYMBOLS = ["FormAutofillUtils"];
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 
+const ADDRESS_REFERENCES = "chrome://formautofill/content/addressReferences.js";
+
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 
 this.FormAutofillUtils = {
   _fieldNameInfo: {
     "name": "name",
     "given-name": "name",
     "additional-name": "name",
     "family-name": "name",
@@ -27,16 +29,17 @@ this.FormAutofillUtils = {
     "country": "address",
     "tel": "tel",
     "email": "email",
     "cc-name": "creditCard",
     "cc-number": "creditCard",
     "cc-exp-month": "creditCard",
     "cc-exp-year": "creditCard",
   },
+  _addressDataLoaded: false,
 
   isAddressField(fieldName) {
     return !!this._fieldNameInfo[fieldName] && !this.isCreditCardField(fieldName);
   },
 
   isCreditCardField(fieldName) {
     return this._fieldNameInfo[fieldName] == "creditCard";
   },
@@ -154,13 +157,122 @@ this.FormAutofillUtils = {
         log.debug("Label found in input's parent or ancestor.");
         return [parent];
       }
       parent = parent.parentNode;
     } while (parent);
 
     return [];
   },
+
+  loadDataFromScript(url, sandbox = {}) {
+    let scriptLoader = Cc["@mozilla.org/moz/jssubscript-loader;1"]
+                         .getService(Ci.mozIJSSubScriptLoader);
+    scriptLoader.loadSubScript(url, sandbox, "utf-8");
+    return sandbox;
+  },
+
+  /**
+   * Find the option element from select element.
+   * 1. Try to find the locale using the country from profile.
+   * 2. First pass try to find exact match.
+   * 3. Second pass try to identify values from profile value and options,
+   *    and look for a match.
+   * @param   {DOMElement} selectEl
+   * @param   {object} profile
+   * @param   {string} fieldName
+   * @returns {DOMElement}
+   */
+  findSelectOption(selectEl, profile, fieldName) {
+    let value = profile[fieldName];
+    if (!value) {
+      return null;
+    }
+
+    // Load the addressData if needed
+    if (!this._addressDataLoaded) {
+      Object.assign(this, this.loadDataFromScript(ADDRESS_REFERENCES));
+      this._addressDataLoaded = true;
+    }
+
+    // Set dataset to "data/US" as fallback
+    let dataset = this.addressData[`data/${profile.country}`] ||
+                  this.addressData["data/US"];
+    let collator = new Intl.Collator(dataset.lang, {sensitivity: "base", ignorePunctuation: true});
+
+    for (let option of selectEl.options) {
+      if (this.strCompare(value, option.value, collator) ||
+          this.strCompare(value, option.text, collator)) {
+        return option;
+      }
+    }
+
+    if (fieldName === "address-level1") {
+      if (!Array.isArray(dataset.sub_keys)) {
+        dataset.sub_keys = dataset.sub_keys.split("~");
+      }
+      if (!Array.isArray(dataset.sub_names)) {
+        dataset.sub_names = dataset.sub_names.split("~");
+      }
+      let keys = dataset.sub_keys;
+      let names = dataset.sub_names;
+      let identifiedValue = this.identifyValue(keys, names, value, collator);
+
+      // No point going any further if we cannot identify value from profile
+      if (identifiedValue === undefined) {
+        return null;
+      }
+
+      // Go through options one by one to find a match.
+      // Also check if any option contain the address-level1 key.
+      let pattern = new RegExp(`\\b${identifiedValue}\\b`, "i");
+      for (let option of selectEl.options) {
+        let optionValue = this.identifyValue(keys, names, option.value, collator);
+        let optionText = this.identifyValue(keys, names, option.text, collator);
+        if (identifiedValue === optionValue || identifiedValue === optionText || pattern.test(option.value)) {
+          return option;
+        }
+      }
+    }
+
+    if (fieldName === "country") {
+      // TODO: Support matching countries (Bug 1375382)
+    }
+
+    return null;
+  },
+
+  /**
+   * Try to match value with keys and names, but always return the key.
+   * @param   {array<string>} keys
+   * @param   {array<string>} names
+   * @param   {string} value
+   * @param   {object} collator
+   * @returns {string}
+   */
+  identifyValue(keys, names, value, collator) {
+    let resultKey = keys.find(key => this.strCompare(value, key, collator));
+    if (resultKey) {
+      return resultKey;
+    }
+
+    let index = names.findIndex(name => this.strCompare(value, name, collator));
+    if (index !== -1) {
+      return keys[index];
+    }
+
+    return null;
+  },
+
+  /**
+   * Compare if two strings are the same.
+   * @param   {string} a
+   * @param   {string} b
+   * @param   {object} collator
+   * @returns {boolean}
+   */
+  strCompare(a = "", b = "", collator) {
+    return !collator.compare(a, b);
+  },
 };
 
 this.log = null;
 this.FormAutofillUtils.defineLazyLogGetter(this, this.EXPORTED_SYMBOLS[0]);
-
new file mode 100644
--- /dev/null
+++ b/browser/extensions/formautofill/content/addressReferences.js
@@ -0,0 +1,15 @@
+/* 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 addressData */
+/* eslint max-len: 0 */
+
+"use strict";
+
+// The data below is initially copied from
+// https://chromium-i18n.appspot.com/ssl-aggregate-address
+
+var addressData = {
+  "data/US": {"lang": "en", "upper": "CS", "sub_zipexs": "35000,36999~99500,99999~96799~85000,86999~71600,72999~34000,34099~09000,09999~96200,96699~90000,96199~80000,81999~06000,06999~19700,19999~20000,56999~32000,34999~30000,39901~96910,96932~96700,96899~83200,83999~60000,62999~46000,47999~50000,52999~66000,67999~40000,42799~70000,71599~03900,04999~96960,96979~20600,21999~01000,05544~48000,49999~96941,96944~55000,56799~38600,39799~63000,65999~59000,59999~68000,69999~88900,89999~03000,03899~07000,08999~87000,88499~10000,00544~27000,28999~58000,58999~96950,96952~43000,45999~73000,74999~97000,97999~96940~15000,19699~00600,00999~02800,02999~29000,29999~57000,57999~37000,38599~75000,73344~84000,84999~05000,05999~00800,00899~20100,24699~98000,99499~24700,26999~53000,54999~82000,83414", "zipex": "95014,22162-1010", "name": "UNITED STATES", "zip": "(\\d{5})(?:[ \\-](\\d{4}))?", "zip_name_type": "zip", "fmt": "%N%n%O%n%A%n%C, %S %Z", "state_name_type": "state", "id": "data/US", "languages": "en", "sub_keys": "AL~AK~AS~AZ~AR~AA~AE~AP~CA~CO~CT~DE~DC~FL~GA~GU~HI~ID~IL~IN~IA~KS~KY~LA~ME~MH~MD~MA~MI~FM~MN~MS~MO~MT~NE~NV~NH~NJ~NM~NY~NC~ND~MP~OH~OK~OR~PW~PA~PR~RI~SC~SD~TN~TX~UT~VT~VI~VA~WA~WV~WI~WY", "key": "US", "posturl": "https://tools.usps.com/go/ZipLookupAction!input.action", "require": "ACSZ", "sub_names": "Alabama~Alaska~American Samoa~Arizona~Arkansas~Armed Forces (AA)~Armed Forces (AE)~Armed Forces (AP)~California~Colorado~Connecticut~Delaware~District of Columbia~Florida~Georgia~Guam~Hawaii~Idaho~Illinois~Indiana~Iowa~Kansas~Kentucky~Louisiana~Maine~Marshall Islands~Maryland~Massachusetts~Michigan~Micronesia~Minnesota~Mississippi~Missouri~Montana~Nebraska~Nevada~New Hampshire~New Jersey~New Mexico~New York~North Carolina~North Dakota~Northern Mariana Islands~Ohio~Oklahoma~Oregon~Palau~Pennsylvania~Puerto Rico~Rhode Island~South Carolina~South Dakota~Tennessee~Texas~Utah~Vermont~Virgin Islands~Virginia~Washington~West Virginia~Wisconsin~Wyoming", "sub_zips": "3[56]~99[5-9]~96799~8[56]~71[6-9]|72~340~09~96[2-6]~9[0-5]|96[01]~8[01]~06~19[7-9]~20[02-5]|569~3[23]|34[1-9]~3[01]|398|39901~969([1-2]\\d|3[12])~967[0-8]|9679[0-8]|968~83[2-9]~6[0-2]~4[67]~5[0-2]~6[67]~4[01]|42[0-7]~70|71[0-5]~039|04~969[67]~20[6-9]|21~01|02[0-7]|05501|05544~4[89]~9694[1-4]~55|56[0-7]~38[6-9]|39[0-7]~6[3-5]~59~6[89]~889|89~03[0-8]~0[78]~87|88[0-4]~1[0-4]|06390|00501|00544~2[78]~58~9695[0-2]~4[3-5]~7[34]~97~969(39|40)~1[5-8]|19[0-6]~00[679]~02[89]~29~57~37|38[0-5]~7[5-9]|885|73301|73344~84~05~008~201|2[23]|24[0-6]~98|99[0-4]~24[7-9]|2[56]~5[34]~82|83[01]|83414"},
+};
--- a/browser/extensions/formautofill/test/mochitest/test_basic_autocomplete_form.html
+++ b/browser/extensions/formautofill/test/mochitest/test_basic_autocomplete_form.html
@@ -20,21 +20,23 @@ Form autofill test: simple form address 
 "use strict";
 
 let expectingPopup = null;
 let MOCK_STORAGE = [{
   organization: "Sesame Street",
   "street-address": "123 Sesame Street.",
   tel: "1-345-345-3456",
   country: "US",
+  "address-level1": "NY",
 }, {
   organization: "Mozilla",
   "street-address": "331 E. Evelyn Avenue",
   tel: "1-650-903-0800",
   country: "US",
+  "address-level1": "CA",
 }];
 
 function expectPopup() {
   info("expecting a popup");
   return new Promise(resolve => {
     expectingPopup = resolve;
   });
 }
@@ -188,16 +190,22 @@ registerPopupShownListener(popupShownLis
     <p>This is a basic form.</p>
     <p><label>organization: <input id="organization" name="organization" autocomplete="organization" type="text"></label></p>
     <p><label>streetAddress: <input id="street-address" name="street-address" autocomplete="street-address" type="text"></label></p>
     <p><label>tel: <input id="tel" name="tel" autocomplete="tel" type="text"></label></p>
     <p><label>email: <input id="email" name="email" autocomplete="email" type="text"></label></p>
     <p><label>country: <select id="country" name="country" autocomplete="country">
       <option/>
       <option value="US">United States</option>
-    </label></p>
+    </select></label></p>
+    <p><label>states: <select id="address-level1" name="address-level1" autocomplete="address-level1">
+      <option/>
+      <option value="CA">California</option>
+      <option value="NY">New York</option>
+      <option value="WA">Washington</option>
+    </select></label></p>
   </form>
 
 </div>
 
 <pre id="test"></pre>
 </body>
 </html>
--- a/browser/extensions/formautofill/test/unit/test_autofillFormFields.js
+++ b/browser/extensions/formautofill/test/unit/test_autofillFormFields.js
@@ -245,16 +245,73 @@ const TESTCASES_INPUT_UNCHANGED = [
     },
     expectedResult: {
       "country": "US",
       "state": "",
     },
   },
 ];
 
+const TESTCASES_US_STATES = [
+  {
+    description: "Form with US states select elements; with lower case state key",
+    document: `<form><select id="state" autocomplete="shipping address-level1">
+                 <option value=""></option>
+                 <option value="CA">California</option>
+               </select></form>`,
+    fieldDetails: [
+      {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "address-level1", "element": {}},
+    ],
+    profileData: {
+      "guid": "123",
+      "country": "US",
+      "address-level1": "ca",
+    },
+    expectedResult: {
+      "state": "CA",
+    },
+  },
+  {
+    description: "Form with US states select elements; with state name and extra spaces",
+    document: `<form><select id="state" autocomplete="shipping address-level1">
+                 <option value=""></option>
+                 <option value="CA">CA</option>
+               </select></form>`,
+    fieldDetails: [
+      {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "address-level1", "element": {}},
+    ],
+    profileData: {
+      "guid": "123",
+      "country": "US",
+      "address-level1": " California ",
+    },
+    expectedResult: {
+      "state": "CA",
+    },
+  },
+  {
+    description: "Form with US states select elements; with partial state key match",
+    document: `<form><select id="state" autocomplete="shipping address-level1">
+                 <option value=""></option>
+                 <option value="US-WA">WA-Washington</option>
+               </select></form>`,
+    fieldDetails: [
+      {"section": "", "addressType": "shipping", "contactType": "", "fieldName": "address-level1", "element": {}},
+    ],
+    profileData: {
+      "guid": "123",
+      "country": "US",
+      "address-level1": "WA",
+    },
+    expectedResult: {
+      "state": "US-WA",
+    },
+  },
+];
+
 function do_test(testcases, testFn) {
   for (let tc of testcases) {
     (function() {
       let testcase = tc;
       add_task(async function() {
         do_print("Starting testcase: " + testcase.description);
 
         let doc = MockDocument.createTestDocument("http://localhost:8080/test/",
@@ -322,8 +379,21 @@ do_test(TESTCASES_INPUT_UNCHANGED, (test
         clearTimeout(timer);
         reject(`${event.type} event should not fire`);
       };
       element.addEventListener("change", cleaner);
       element.addEventListener("input", cleaner);
     }),
   ];
 });
+
+do_test(TESTCASES_US_STATES, (testcase, element) => {
+  let id = element.id;
+  return [
+    new Promise(resolve => {
+      element.addEventListener("input", () => {
+        Assert.equal(element.value, testcase.expectedResult[id],
+                    "Check the " + id + " field was filled with correct data");
+        resolve();
+      }, {once: true});
+    }),
+  ];
+});