Bug 1420761 - Handle new localization API in Preferences Search. r=jaws draft
authorZibi Braniecki <zbraniecki@mozilla.com>
Mon, 19 Mar 2018 21:17:45 -0700
changeset 769761 98f647b6e677d8e0d83b23ad7354599f62fc745f
parent 769760 d9da226376e0159f0283f5329761a48b912025f9
child 770407 accbab7d66c54ddc35c42992e690173c45defced
push id103219
push userbmo:gandalf@aviary.pl
push dateTue, 20 Mar 2018 04:17:59 +0000
reviewersjaws
bugs1420761
milestone61.0a1
Bug 1420761 - Handle new localization API in Preferences Search. r=jaws MozReview-Commit-ID: 8J1siQtFn1t
browser/components/preferences/in-content/findInPage.js
browser/components/preferences/in-content/tests/browser_search_within_preferences_1.js
browser/components/preferences/in-content/tests/browser_search_within_preferences_2.js
--- a/browser/components/preferences/in-content/findInPage.js
+++ b/browser/components/preferences/in-content/findInPage.js
@@ -4,16 +4,19 @@
 
 /* import-globals-from extensionControlled.js */
 /* import-globals-from preferences.js */
 
 var gSearchResultsPane = {
   listSearchTooltips: new Set(),
   listSearchMenuitemIndicators: new Set(),
   searchInput: null,
+  // A map of DOM Elements to a string of keywords used in search
+  // XXX: We should invalidate this cache on `intl:app-locales-changed`
+  searchKeywords: new WeakMap(),
   inited: false,
 
   init() {
     if (this.inited) {
       return;
     }
     this.inited = true;
     this.searchInput = document.getElementById("searchInput");
@@ -259,17 +262,17 @@ var gSearchResultsPane = {
           ts = await new Promise(resolve => window.requestAnimationFrame(resolve));
           if (query !== this.query) {
             return;
           }
         }
 
         if (!child.classList.contains("header") &&
             !child.classList.contains("subcategory") &&
-            this.searchWithinNode(child, this.query)) {
+            await this.searchWithinNode(child, this.query)) {
           child.hidden = false;
           child.classList.remove("visually-hidden");
 
           // Show the preceding search-header if one exists.
           let groupbox = child.closest("groupbox");
           let groupHeader = groupbox && groupbox.querySelector(".search-header");
           if (groupHeader) {
             groupHeader.hidden = false;
@@ -322,17 +325,17 @@ var gSearchResultsPane = {
    * It is a recursive function
    *
    * @param Node nodeObject
    *    DOM Element
    * @param String searchPhrase
    * @returns boolean
    *    Returns true when found in at least one childNode, false otherwise
    */
-  searchWithinNode(nodeObject, searchPhrase) {
+  async searchWithinNode(nodeObject, searchPhrase) {
     let matchesFound = false;
     if (nodeObject.childElementCount == 0 ||
         nodeObject.tagName == "label" ||
         nodeObject.tagName == "description" ||
         nodeObject.tagName == "menulist") {
       let simpleTextNodes = this.textNodeDescendants(nodeObject);
       for (let node of simpleTextNodes) {
         let result = this.highlightMatches([node], [node.length], node.textContent.toLowerCase(), searchPhrase);
@@ -361,18 +364,30 @@ var gSearchResultsPane = {
       // Searching some elements, such as xul:button, have a 'label' attribute that contains the user-visible text.
       let labelResult = this.queryMatchesContent(nodeObject.getAttribute("label"), searchPhrase);
 
       // Searching some elements, such as xul:label, store their user-visible text in a "value" attribute.
       // Value will be skipped for menuitem since value in menuitem could represent index number to distinct each item.
       let valueResult = nodeObject.tagName !== "menuitem" ?
         this.queryMatchesContent(nodeObject.getAttribute("value"), searchPhrase) : false;
 
-      // Searching some elements, such as xul:button, buttons to open subdialogs.
-      let keywordsResult = this.queryMatchesContent(nodeObject.getAttribute("searchkeywords"), searchPhrase);
+      // Searching some elements, such as xul:button, buttons to open subdialogs
+      // using l10n ids.
+      let keywordsResult =
+        nodeObject.hasAttribute("search-l10n-ids") &&
+        await this.matchesSearchL10nIDs(nodeObject, searchPhrase);
+
+      if (!keywordsResult) {
+        // Searching some elements, such as xul:button, buttons to open subdialogs
+        // using searchkeywords attribute.
+        keywordsResult =
+          !keywordsResult &&
+          nodeObject.hasAttribute("searchkeywords") &&
+          this.queryMatchesContent(nodeObject.getAttribute("searchkeywords"), searchPhrase);
+      }
 
       // Creating tooltips for buttons
       if (keywordsResult && (nodeObject.tagName === "button" || nodeObject.tagName == "menulist")) {
         this.listSearchTooltips.add(nodeObject);
       }
 
       if (keywordsResult && nodeObject.tagName === "menuitem") {
         nodeObject.setAttribute("indicator", "true");
@@ -393,22 +408,22 @@ var gSearchResultsPane = {
       matchesFound = matchesFound || complexTextNodesResult || labelResult || valueResult || keywordsResult;
     }
 
     // Should not search unselected child nodes of a <xul:deck> element
     // except the "historyPane" <xul:deck> element.
     if (nodeObject.tagName == "deck" && nodeObject.id != "historyPane") {
       let index = nodeObject.selectedIndex;
       if (index != -1) {
-        let result = this.searchChildNodeIfVisible(nodeObject, index, searchPhrase);
+        let result = await this.searchChildNodeIfVisible(nodeObject, index, searchPhrase);
         matchesFound = matchesFound || result;
       }
     } else {
       for (let i = 0; i < nodeObject.childNodes.length; i++) {
-        let result = this.searchChildNodeIfVisible(nodeObject, i, searchPhrase);
+        let result = await this.searchChildNodeIfVisible(nodeObject, i, searchPhrase);
         matchesFound = matchesFound || result;
       }
     }
     return matchesFound;
   },
 
   /**
    * Search for a phrase within a child node if it is visible.
@@ -416,29 +431,79 @@ var gSearchResultsPane = {
    * @param Node nodeObject
    *    The parent DOM Element
    * @param Number index
    *    The index for the childNode
    * @param String searchPhrase
    * @returns boolean
    *    Returns true when found the specific childNode, false otherwise
    */
-  searchChildNodeIfVisible(nodeObject, index, searchPhrase) {
+  async searchChildNodeIfVisible(nodeObject, index, searchPhrase) {
     let result = false;
     if (!nodeObject.childNodes[index].hidden && nodeObject.getAttribute("data-hidden-from-search") !== "true") {
-      result = this.searchWithinNode(nodeObject.childNodes[index], searchPhrase);
+      result = await this.searchWithinNode(nodeObject.childNodes[index], searchPhrase);
       // Creating tooltips for menulist element
       if (result && nodeObject.tagName === "menulist") {
         this.listSearchTooltips.add(nodeObject);
       }
     }
     return result;
   },
 
   /**
+   * Search for a phrase in l10n messages associated with the element.
+   *
+   * @param Node nodeObject
+   *    The parent DOM Element
+   * @param String searchPhrase
+   * @returns boolean
+   *    true when the text content contains the query string else false
+   */
+  async matchesSearchL10nIDs(nodeObject, searchPhrase) {
+    if (!this.searchKeywords.has(nodeObject)) {
+      // The `search-l10n-ids` attribute is a comma-separated list of
+      // l10n ids. It may also uses a dot notation to specify an attribute
+      // of the message to be used.
+      //
+      // Example: "containers-add-button.label, user-context-personal"
+      //
+      // The result is an array of arrays of l10n ids and optionally attribute names.
+      //
+      // Example: [["containers-add-button", "label"], ["user-context-personal"]]
+      const refs = nodeObject.getAttribute("search-l10n-ids")
+        .split(",")
+        .map(s => s.trim().split(".")).filter(s => s[0].length > 0);
+
+      const messages = await document.l10n.formatMessages(refs.map(ref => [ref[0]]));
+
+      // Map the localized messages taking value or a selected attribute and
+      // building a string of concatenated translated strings out of it.
+      let keywords = messages.map((msg, i) => {
+        if (msg === null) {
+          console.warn(`Missing search l10n id "${refs[i][0]}"`);
+          return null;
+        }
+        if (refs[i][1]) {
+          let attr = msg.attrs.find(a => a.name === refs[i][1]);
+          if (attr) {
+            return attr.value;
+          }
+          return null;
+        }
+        return msg.value;
+      }).filter(keyword => keyword !== null).join(" ");
+
+      this.searchKeywords.set(nodeObject, keywords);
+      return this.queryMatchesContent(keywords, searchPhrase);
+    }
+
+    return this.queryMatchesContent(this.searchKeywords.get(nodeObject), searchPhrase);
+  },
+
+  /**
    * Inserting a div structure infront of the DOM element matched textContent.
    * Then calculation the offsets to position the tooltip in the correct place.
    *
    * @param Node anchorNode
    *    DOM Element
    * @param String query
    *    Word or words that are being searched for
    */
--- a/browser/components/preferences/in-content/tests/browser_search_within_preferences_1.js
+++ b/browser/components/preferences/in-content/tests/browser_search_within_preferences_1.js
@@ -138,43 +138,46 @@ add_task(async function search_for_passw
 
 /**
  * Test for if nothing is found
  */
 add_task(async function search_with_nothing_found() {
   await openPreferencesViaOpenPreferencesAPI("paneGeneral", { leaveOpen: true });
 
   let noResultsEl = gBrowser.contentDocument.querySelector("#no-results-message");
+  let sorryMsgQueryEl = gBrowser.contentDocument.getElementById("sorry-message-query");
 
   is_element_hidden(noResultsEl, "Should not be in search results yet");
 
   // Performs search
   let searchInput = gBrowser.contentDocument.getElementById("searchInput");
 
   is(searchInput, gBrowser.contentDocument.activeElement.closest("#searchInput"),
     "Search input should be focused when visiting preferences");
 
   let query = "coach";
   let searchCompletedPromise = BrowserTestUtils.waitForEvent(
     gBrowser.contentWindow, "PreferencesSearchCompleted", evt => evt.detail == query);
   EventUtils.sendString(query);
   await searchCompletedPromise;
 
   is_element_visible(noResultsEl, "Should be in search results");
+  is(sorryMsgQueryEl.textContent, query, "sorry-message-query should contain the query");
 
   // Takes search off
   searchCompletedPromise = BrowserTestUtils.waitForEvent(
     gBrowser.contentWindow, "PreferencesSearchCompleted", evt => evt.detail == "");
   let count = query.length;
   while (count--) {
     EventUtils.sendKey("BACK_SPACE");
   }
   await searchCompletedPromise;
 
   is_element_hidden(noResultsEl, "Should not be in search results");
+  is(sorryMsgQueryEl.textContent.length, 0, "sorry-message-query should be empty");
 
   await BrowserTestUtils.removeTab(gBrowser.selectedTab);
 });
 
 /**
  * Test for if we go back to general tab after search case
  */
 add_task(async function exiting_search_reverts_to_general_pane() {
--- a/browser/components/preferences/in-content/tests/browser_search_within_preferences_2.js
+++ b/browser/components/preferences/in-content/tests/browser_search_within_preferences_2.js
@@ -60,8 +60,69 @@ add_task(async function() {
   EventUtils.sendString(query);
   await searchCompletedPromise;
 
   let noResultsEl = gBrowser.contentDocument.querySelector("#no-results-message");
   is_element_visible(noResultsEl, "Should be reporting no results");
 
   await BrowserTestUtils.removeTab(gBrowser.selectedTab);
 });
+
+
+/**
+ * Test that we search using `search-l10n-ids`.
+ *
+ * The test uses element `browserContainersSettings` and
+ * l10n id `language-and-appearance-header` and expects the element
+ * to be matched on the first word from the l10n id value ("Language" in en-US).
+ */
+add_task(async function() {
+  let l10nId = "language-and-appearance-header";
+
+  await openPreferencesViaOpenPreferencesAPI("paneGeneral", {leaveOpen: true});
+
+  // First, lets make sure that the element is not matched without
+  // `search-l10n-ids`.
+  {
+    let searchInput = gBrowser.contentDocument.getElementById("searchInput");
+    let bcsElem = gBrowser.contentDocument.getElementById("browserContainersSettings");
+
+    is(searchInput, gBrowser.contentDocument.activeElement.closest("#searchInput"),
+      "Search input should be focused when visiting preferences");
+
+    ok(!bcsElem.getAttribute("search-l10n-ids").includes(l10nId),
+      "browserContainersSettings element should not contain the l10n id here.");
+
+    let query = "Language";
+    let searchCompletedPromise = BrowserTestUtils.waitForEvent(
+        gBrowser.contentWindow, "PreferencesSearchCompleted", evt => evt.detail == query);
+    EventUtils.sendString(query);
+    await searchCompletedPromise;
+
+    is_element_hidden(bcsElem, "browserContainersSettings should not be in search results");
+  }
+
+  await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+  // Now, let's add the l10n id to the element and perform the same search again.
+
+  await openPreferencesViaOpenPreferencesAPI("paneGeneral", {leaveOpen: true});
+
+  {
+    let searchInput = gBrowser.contentDocument.getElementById("searchInput");
+
+    is(searchInput, gBrowser.contentDocument.activeElement.closest("#searchInput"),
+      "Search input should be focused when visiting preferences");
+
+    let bcsElem = gBrowser.contentDocument.getElementById("browserContainersSettings");
+    bcsElem.setAttribute("search-l10n-ids", l10nId);
+
+    let query = "Language";
+    let searchCompletedPromise = BrowserTestUtils.waitForEvent(
+        gBrowser.contentWindow, "PreferencesSearchCompleted", evt => evt.detail == query);
+    EventUtils.sendString(query);
+    await searchCompletedPromise;
+
+    is_element_visible(bcsElem, "browserContainersSettings should be in search results");
+  }
+
+  await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});