Bug 1365544 - Handle filling inexact matches on address-level1 select fields. r=lchang draft
authorScott Wu <scottcwwu@gmail.com>
Tue, 16 May 2017 16:53:01 +0800
changeset 598759 71cf037373ae03c54d5975b788de674266440806
parent 598703 dc33e00dad90346466fefaa158bc0d79a53668a9
child 634576 f1bb1c5c1d6e3c716c5a5e0bc222c0088d3afb0b
push id65316
push userbmo:scwwu@mozilla.com
push dateThu, 22 Jun 2017 07:16:58 +0000
reviewerslchang
bugs1365544
milestone56.0a1
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});
+    }),
+  ];
+});