Bug 1019483 - (Part 1) Create interface to manage autofill profiles. r=MattN draft
authorScott Wu <scottcwwu@gmail.com>
Mon, 06 Mar 2017 15:56:51 +0800
changeset 554264 b7921f50d0c515e207659778515f55502f4c1c89
parent 553822 8df9fabf2587b7020889755acb9e75b664fe13cf
child 554265 84f4af176e29fddde97442cebfbd1f4279182c75
push id51886
push userbmo:scwwu@mozilla.com
push dateFri, 31 Mar 2017 09:38:56 +0000
reviewersMattN
bugs1019483
milestone55.0a1
Bug 1019483 - (Part 1) Create interface to manage autofill profiles. r=MattN MozReview-Commit-ID: KrGSPz7B108
browser/base/content/test/static/browser_all_files_referenced.js
browser/extensions/formautofill/FormAutofillParent.jsm
browser/extensions/formautofill/FormAutofillPreferences.jsm
browser/extensions/formautofill/content/editProfile.css
browser/extensions/formautofill/content/editProfile.xhtml
browser/extensions/formautofill/content/manageProfiles.css
browser/extensions/formautofill/content/manageProfiles.js
browser/extensions/formautofill/content/manageProfiles.xhtml
toolkit/themes/shared/in-content/common.inc.css
--- a/browser/base/content/test/static/browser_all_files_referenced.js
+++ b/browser/base/content/test/static/browser_all_files_referenced.js
@@ -127,18 +127,16 @@ var whitelist = new Set([
   {file: "chrome://browser/skin/customizableui/customize-illustration@2x.png",
    platforms: ["linux", "win"]},
   {file: "chrome://browser/skin/customizableui/info-icon-customizeTip@2x.png",
    platforms: ["linux", "win"]},
   {file: "chrome://browser/skin/customizableui/panelarrow-customizeTip@2x.png",
    platforms: ["linux", "win"]},
   // Bug 1320058
   {file: "chrome://browser/skin/preferences/saveFile.png", platforms: ["win"]},
-  // Bug 1348369
-  {file: "chrome://formautofill/content/editProfile.xhtml"},
   // Bug 1316187
   {file: "chrome://global/content/customizeToolbar.xul"},
   // Bug 1343837
   {file: "chrome://global/content/findUtils.js"},
   // Bug 1343843
   {file: "chrome://global/content/url-classifier/unittests.xul"},
   // Bug 1343839
   {file: "chrome://global/locale/headsUpDisplay.properties"},
--- a/browser/extensions/formautofill/FormAutofillParent.jsm
+++ b/browser/extensions/formautofill/FormAutofillParent.jsm
@@ -71,17 +71,19 @@ FormAutofillParent.prototype = {
    */
   init() {
     log.debug("init");
     let storePath = OS.Path.join(OS.Constants.Path.profileDir, PROFILE_JSON_FILE_NAME);
     this._profileStore = new ProfileStorage(storePath);
     this._profileStore.initialize();
 
     Services.obs.addObserver(this, "advanced-pane-loaded", false);
+    Services.ppmm.addMessageListener("FormAutofill:GetProfiles", this);
     Services.ppmm.addMessageListener("FormAutofill:SaveProfile", this);
+    Services.ppmm.addMessageListener("FormAutofill:RemoveProfiles", this);
 
     // Observing the pref and storage changes
     Services.prefs.addObserver(ENABLED_PREF, this, false);
     Services.obs.addObserver(this, "formautofill-storage-changed", false);
 
     // Force to trigger the onStatusChanged function for setting listeners properly
     // while initizlization
     this._setStatus(this._getStatus());
@@ -126,27 +128,20 @@ FormAutofillParent.prototype = {
 
       default: {
         throw new Error(`FormAutofillParent: Unexpected topic observed: ${topic}`);
       }
     }
   },
 
   /**
-   * Add/remove message listener and broadcast the status to frames while the
-   * form autofill status changed.
+   * Broadcast the status to frames when the form autofill status changes.
    */
   _onStatusChanged() {
     log.debug("_onStatusChanged: Status changed to", this._enabled);
-    if (this._enabled) {
-      Services.ppmm.addMessageListener("FormAutofill:GetProfiles", this);
-    } else {
-      Services.ppmm.removeMessageListener("FormAutofill:GetProfiles", this);
-    }
-
     Services.ppmm.broadcastAsyncMessage("FormAutofill:enabledStatus", this._enabled);
     // Sync process data autofillEnabled to make sure the value up to date
     // no matter when the new content process is initialized.
     Services.ppmm.initialProcessData.autofillEnabled = this._enabled;
   },
 
   /**
    * Query pref and storage status to determine the overall status for
@@ -188,16 +183,20 @@ FormAutofillParent.prototype = {
       case "FormAutofill:SaveProfile": {
         if (data.guid) {
           this.getProfileStore().update(data.guid, data.profile);
         } else {
           this.getProfileStore().add(data.profile);
         }
         break;
       }
+      case "FormAutofill:RemoveProfiles": {
+        data.guids.forEach(guid => this.getProfileStore().remove(guid));
+        break;
+      }
     }
   },
 
   /**
    * Returns the instance of ProfileStorage. To avoid syncing issues, anyone
    * who needs to access the profile should request the instance by this instead
    * of creating a new one.
    *
@@ -215,16 +214,17 @@ FormAutofillParent.prototype = {
   _uninit() {
     if (this._profileStore) {
       this._profileStore._saveImmediately();
       this._profileStore = null;
     }
 
     Services.ppmm.removeMessageListener("FormAutofill:GetProfiles", this);
     Services.ppmm.removeMessageListener("FormAutofill:SaveProfile", this);
+    Services.ppmm.removeMessageListener("FormAutofill:RemoveProfiles", this);
     Services.obs.removeObserver(this, "advanced-pane-loaded");
     Services.prefs.removeObserver(ENABLED_PREF, this);
   },
 
   /**
    * Get the profile data from profile store and return profiles back to content process.
    *
    * @private
--- a/browser/extensions/formautofill/FormAutofillPreferences.jsm
+++ b/browser/extensions/formautofill/FormAutofillPreferences.jsm
@@ -8,16 +8,17 @@
 
 "use strict";
 
 this.EXPORTED_SYMBOLS = ["FormAutofillPreferences"];
 
 const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
 const PREF_AUTOFILL_ENABLED = "browser.formautofill.enabled";
 const BUNDLE_URI = "chrome://formautofill/locale/formautofill.properties";
+const MANAGE_PROFILES_URL = "chrome://formautofill/content/manageProfiles.xhtml";
 
 Cu.import("resource://gre/modules/Services.jsm");
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://formautofill/FormAutofillUtils.jsm");
 
 this.log = null;
 FormAutofillUtils.defineLazyLogGetter(this, this.EXPORTED_SYMBOLS[0]);
 
@@ -121,17 +122,17 @@ FormAutofillPreferences.prototype = {
     switch (event.type) {
       case "command": {
         let target = event.target;
 
         if (target == this.refs.enabledCheckbox) {
           // Set preference directly instead of relying on <Preference>
           Services.prefs.setBoolPref(PREF_AUTOFILL_ENABLED, target.checked);
         } else if (target == this.refs.savedProfilesBtn) {
-          // TODO: Open Saved Profiles dialog
+          target.ownerGlobal.gSubDialog.open(MANAGE_PROFILES_URL);
         }
         break;
       }
     }
   },
 
   /**
    * Attach event listener
--- a/browser/extensions/formautofill/content/editProfile.css
+++ b/browser/extensions/formautofill/content/editProfile.css
@@ -1,14 +1,17 @@
 /* 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/. */
 
 body {
   font-size: 1rem;
+  /* body needs padding until the edit profile dialog could be loaded as a
+     stacked subdialog */
+  padding: 2em;
 }
 
 form,
 label,
 div {
   display: flex;
 }
 
--- a/browser/extensions/formautofill/content/editProfile.xhtml
+++ b/browser/extensions/formautofill/content/editProfile.xhtml
@@ -1,16 +1,20 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!-- 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/. -->
 <!DOCTYPE html>
 <html xmlns="http://www.w3.org/1999/xhtml">
 <head>
   <title>Profile Autofill - Edit Profile</title>
+  <!-- common.css and dialog.css need to be included until this file can be
+     - loaded as a stacked subdialog. -->
+  <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+  <link rel="stylesheet" href="chrome://browser/skin/preferences/in-content/dialog.css" />
   <link rel="stylesheet" href="chrome://formautofill/content/editProfile.css" />
   <script src="chrome://formautofill/content/editProfile.js"></script>
 </head>
 <body>
   <form>
     <label id="first-name-container">
       <span>First Name</span>
       <input id="first-name" type="text"/>
new file mode 100644
--- /dev/null
+++ b/browser/extensions/formautofill/content/manageProfiles.css
@@ -0,0 +1,60 @@
+/* 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/. */
+
+div {
+  display: flex;
+}
+
+button {
+  padding: 3px 2em;
+}
+
+fieldset {
+  margin: 0;
+  padding: 0;
+  border: none;
+}
+
+fieldset > legend {
+  box-sizing: border-box;
+  width: 100%;
+  padding: 0.4em 0.7em;
+  font-size: 0.9em;
+  color: #808080;
+  background-color: var(--in-content-box-background-hover);
+  border: 1px solid var(--in-content-box-border-color);
+  border-radius: 2px 2px 0 0;
+}
+
+option:nth-child(even) {
+  background-color: -moz-oddtreerow;
+}
+
+#profiles {
+  font-size: 0.85em;
+  width: 100%;
+  height: 16.6em;
+  border-top: none;
+  border-radius: 0 0 2px 2px;
+}
+
+#profiles > option {
+  padding-inline-start: 0.7em;
+}
+
+#controls-container {
+  flex: 0 1 100%;
+  justify-content: end;
+  font-size: 0.9em;
+  margin-top: 1em;
+}
+
+#remove {
+  margin-inline-start: 0;
+  margin-inline-end: auto;
+}
+
+#edit {
+  margin-inline-end: 0;
+}
\ No newline at end of file
new file mode 100644
--- /dev/null
+++ b/browser/extensions/formautofill/content/manageProfiles.js
@@ -0,0 +1,268 @@
+/* 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";
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+const EDIT_PROFILE_URL = "chrome://formautofill/content/editProfile.xhtml";
+
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://formautofill/FormAutofillUtils.jsm");
+
+this.log = null;
+FormAutofillUtils.defineLazyLogGetter(this, "manageProfiles");
+
+function ManageProfileDialog() {
+  window.addEventListener("DOMContentLoaded", this, {once: true});
+}
+
+ManageProfileDialog.prototype = {
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsISupports, Ci.nsIObserver]),
+
+  _elements: {},
+
+  /**
+   * Count the number of "formautofill-storage-changed" events epected to
+   * receive to prevent repeatedly loading profiles.
+   * @type {number}
+   */
+  _pendingChangeCount: 0,
+
+  /**
+   * Get the selected options on the profiles element.
+   *
+   * @returns {array<DOMElement>}
+   */
+  get _selectedOptions() {
+    return Array.from(this._elements.profiles.selectedOptions);
+  },
+
+  init() {
+    this._elements = {
+      profiles: document.getElementById("profiles"),
+      controlsContainer: document.getElementById("controls-container"),
+      remove: document.getElementById("remove"),
+      add: document.getElementById("add"),
+      edit: document.getElementById("edit"),
+    };
+    this.attachEventListeners();
+  },
+
+  uninit() {
+    log.debug("uninit");
+    this.detachEventListeners();
+    this._elements = null;
+  },
+
+  /**
+   * Load profiles and render them.
+   *
+   * @returns {promise}
+   */
+  loadProfiles() {
+    return this.getProfiles().then(profiles => {
+      log.debug("profiles:", profiles);
+      this.renderProfileElements(profiles);
+      this.updateButtonsStates(this._selectedOptions.length);
+    });
+  },
+
+  /**
+   * Get profiles from storage.
+   *
+   * @returns {promise}
+   */
+  getProfiles() {
+    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", {});
+    });
+  },
+
+  /**
+   * Render the profiles onto the page while maintaining selected options if
+   * they still exist.
+   *
+   * @param  {array<object>} profiles
+   */
+  renderProfileElements(profiles) {
+    let selectedGuids = this._selectedOptions.map(option => option.value);
+    this.clearProfileElements();
+    for (let profile of profiles) {
+      let option = new Option(this.getProfileLabel(profile),
+                              profile.guid,
+                              false,
+                              selectedGuids.includes(profile.guid));
+      option.profile = profile;
+      this._elements.profiles.appendChild(option);
+    }
+  },
+
+  /**
+   * Remove all existing profile elements.
+   */
+  clearProfileElements() {
+    let parent = this._elements.profiles;
+    while (parent.lastChild) {
+      parent.removeChild(parent.lastChild);
+    }
+  },
+
+  /**
+   * Remove profiles by guids.
+   * Keep track of the number of "formautofill-storage-changed" events to
+   * ignore before loading profiles.
+   *
+   * @param  {array<string>} guids
+   */
+  removeProfiles(guids) {
+    this._pendingChangeCount += guids.length - 1;
+    Services.cpmm.sendAsyncMessage("FormAutofill:RemoveProfiles", {guids});
+  },
+
+  /**
+   * Get profile display label. It should display up to two pieces of
+   * information, separated by a comma.
+   *
+   * @param  {object} profile
+   * @returns {string}
+   */
+  getProfileLabel(profile) {
+    // TODO: Implement a smarter way for deciding what to display
+    //       as option text. Possibly improve the algorithm in
+    //       ProfileAutoCompleteResult.jsm and reuse it here.
+    const fieldOrder = [
+      "street-address",  // Street address
+      "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
+    ];
+
+    let parts = [];
+    for (const fieldName of fieldOrder) {
+      let string = profile[fieldName];
+      if (string) {
+        parts.push(string);
+      }
+      if (parts.length == 2) {
+        break;
+      }
+    }
+    return parts.join(", ");
+  },
+
+  /**
+   * Open the edit profile dialog to create/edit a profile.
+   *
+   * @param  {object} profile [optional]
+   */
+  openEditDialog(profile) {
+    window.openDialog(EDIT_PROFILE_URL, null,
+                      "chrome,centerscreen,modal,width=600,height=370",
+                      profile);
+  },
+
+  /**
+   * Enable/disable the Edit and Remove buttons based on number of selected
+   * options.
+   *
+   * @param  {number} selectedCount
+   */
+  updateButtonsStates(selectedCount) {
+    log.debug("updateButtonsStates:", selectedCount);
+    if (selectedCount == 0) {
+      this._elements.edit.setAttribute("disabled", "disabled");
+      this._elements.remove.setAttribute("disabled", "disabled");
+    } else if (selectedCount == 1) {
+      this._elements.edit.removeAttribute("disabled");
+      this._elements.remove.removeAttribute("disabled");
+    } else if (selectedCount > 1) {
+      this._elements.edit.setAttribute("disabled", "disabled");
+      this._elements.remove.removeAttribute("disabled");
+    }
+  },
+
+  /**
+   * Handle events
+   *
+   * @param  {DOMEvent} event
+   */
+  handleEvent(event) {
+    switch (event.type) {
+      case "DOMContentLoaded": {
+        this.init();
+        this.loadProfiles();
+        break;
+      }
+      case "click": {
+        this.handleClick(event);
+        break;
+      }
+      case "change": {
+        this.updateButtonsStates(this._selectedOptions.length);
+        break;
+      }
+      case "unload": {
+        this.uninit();
+        break;
+      }
+    }
+  },
+
+  /**
+   * Handle click events
+   *
+   * @param  {DOMEvent} event
+   */
+  handleClick(event) {
+    if (event.target == this._elements.remove) {
+      this.removeProfiles(this._selectedOptions.map(option => option.value));
+    } else if (event.target == this._elements.add) {
+      this.openEditDialog();
+    } else if (event.target == this._elements.edit) {
+      this.openEditDialog(this._selectedOptions[0].profile);
+    }
+  },
+
+  observe(subject, topic, data) {
+    switch (topic) {
+      case "formautofill-storage-changed": {
+        if (this._pendingChangeCount) {
+          this._pendingChangeCount -= 1;
+          return;
+        }
+        this.loadProfiles();
+      }
+    }
+  },
+
+  /**
+   * Attach event listener
+   */
+  attachEventListeners() {
+    window.addEventListener("unload", this, {once: true});
+    this._elements.profiles.addEventListener("change", this);
+    this._elements.controlsContainer.addEventListener("click", this);
+    Services.obs.addObserver(this, "formautofill-storage-changed", false);
+  },
+
+  /**
+   * Remove event listener
+   */
+  detachEventListeners() {
+    this._elements.profiles.removeEventListener("change", this);
+    this._elements.controlsContainer.removeEventListener("click", this);
+    Services.obs.removeObserver(this, "formautofill-storage-changed");
+  },
+};
+
+new ManageProfileDialog();
new file mode 100644
--- /dev/null
+++ b/browser/extensions/formautofill/content/manageProfiles.xhtml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 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/. -->
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+  <title>Profile Autofill - Manage Profiles</title>
+  <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" />
+  <link rel="stylesheet" href="chrome://formautofill/content/manageProfiles.css" />
+  <script src="chrome://formautofill/content/manageProfiles.js"></script>
+</head>
+<body>
+  <fieldset>
+    <legend>Profiles</legend>
+    <select id="profiles" size="9" multiple="multiple"/>
+  </fieldset>
+  <div id="controls-container">
+    <button id="remove" disabled="disabled">Remove</button>
+    <button id="add">Add</button>
+    <button id="edit" disabled="disabled">Edit</button>
+  </div>
+</body>
+</html>
\ No newline at end of file
--- a/toolkit/themes/shared/in-content/common.inc.css
+++ b/toolkit/themes/shared/in-content/common.inc.css
@@ -191,25 +191,25 @@ xul|menulist {
   -moz-border-right-colors: none !important;
   -moz-border-bottom-colors: none !important;
   -moz-border-left-colors: none !important;
   border-radius: 2px;
   background-color: var(--in-content-page-background);
 }
 
 html|button:enabled:hover,
-html|select:enabled:hover,
+html|select:not([size]):not([multiple]):enabled:hover,
 xul|button:not([disabled="true"]):hover,
 xul|colorpicker[type="button"]:not([disabled="true"]):hover,
 xul|menulist:not([disabled="true"]):hover {
   background-color: var(--in-content-box-background-hover);
 }
 
 html|button:enabled:hover:active,
-html|select:enabled:hover:active,
+html|select:not([size]):not([multiple]):enabled:hover:active,
 xul|button:not([disabled="true"]):hover:active,
 xul|colorpicker[type="button"]:not([disabled="true"]):hover:active,
 xul|menulist[open="true"]:not([disabled="true"]) {
   background-color: var(--in-content-box-background-active);
 }
 
 html|button:disabled,
 html|select:disabled,
@@ -706,34 +706,37 @@ xul|filefield + xul|button:-moz-locale-d
 
 xul|textbox + xul|button,
 xul|filefield + xul|button {
   border-inline-start: none;
 }
 
 /* List boxes */
 
+html|select[size][multiple],
 xul|richlistbox,
 xul|listbox {
   -moz-appearance: none;
   margin-inline-start: 0;
   background-color: var(--in-content-box-background);
   border: 1px solid var(--in-content-box-border-color);
   color: var(--in-content-text-color);
 }
 
+html|select[size][multiple] > html|option,
 xul|treechildren::-moz-tree-row,
 xul|listbox xul|listitem {
   padding: 0.3em;
   margin: 0;
   border: none;
   border-radius: 0;
   background-image: none;
 }
 
+html|select[size][multiple] > html|option:hover,
 xul|treechildren::-moz-tree-row(hover),
 xul|listbox xul|listitem:hover {
   background-color: var(--in-content-item-hover);
 }
 
 xul|treechildren::-moz-tree-row(selected),
 xul|listbox xul|listitem[selected="true"] {
   background-color: var(--in-content-item-selected);