Bug 1354504 - Add autocomplete to network monitor search box. r=jdescottes, ntim
authorRuturaj K. Vartak <ruturaj@gmail.com>
Thu, 11 May 2017 04:25:00 +0100
changeset 358062 2757a36546c0b7d2cd7ff50d8a981841f85107f3
parent 358061 2af9492e9b767247f3339563da3a26c4e0ef4445
child 358063 182823a741b4cf53a4f05d42df06acb7488cbb6e
push id31808
push usercbook@mozilla.com
push dateFri, 12 May 2017 12:37:49 +0000
treeherdermozilla-central@030c0a7c8781 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersjdescottes, ntim
bugs1354504
milestone55.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 1354504 - Add autocomplete to network monitor search box. r=jdescottes, ntim MozReview-Commit-ID: KojxbqOAJAQ
devtools/client/netmonitor/src/components/toolbar.js
devtools/client/netmonitor/src/constants.js
devtools/client/netmonitor/src/utils/filter-text-utils.js
devtools/client/netmonitor/webpack.config.js
devtools/client/shared/components/autocomplete-popup.js
devtools/client/shared/components/moz.build
devtools/client/shared/components/search-box.js
devtools/client/themes/common.css
--- a/devtools/client/netmonitor/src/components/toolbar.js
+++ b/devtools/client/netmonitor/src/components/toolbar.js
@@ -7,17 +7,17 @@
 const {
   createClass,
   createFactory,
   DOM,
   PropTypes,
 } = require("devtools/client/shared/vendor/react");
 const { connect } = require("devtools/client/shared/vendor/react-redux");
 const Actions = require("../actions/index");
-const { FILTER_SEARCH_DELAY } = require("../constants");
+const { FILTER_SEARCH_DELAY, FILTER_FLAGS } = require("../constants");
 const {
   getDisplayedRequestsSummary,
   getRequestFilterTypes,
   isNetworkDetailsToggleButtonDisabled,
 } = require("../selectors/index");
 
 const { L10N } = require("../utils/l10n");
 
@@ -104,16 +104,17 @@ const Toolbar = createClass({
         ),
         span({ className: "devtools-toolbar-group" },
           SearchBox({
             delay: FILTER_SEARCH_DELAY,
             keyShortcut: SEARCH_KEY_SHORTCUT,
             placeholder: SEARCH_PLACE_HOLDER,
             type: "filter",
             onChange: setRequestFilterText,
+            autocompleteList: FILTER_FLAGS.map((item) => `${item}:`),
           }),
           button({
             className: toggleButtonClassName.join(" "),
             title: networkDetailsOpen ? COLLPASE_DETAILS_PANE : EXPAND_DETAILS_PANE,
             disabled: networkDetailsToggleDisabled,
             tabIndex: "0",
             onClick: toggleNetworkDetails,
           }),
--- a/devtools/client/netmonitor/src/constants.js
+++ b/devtools/client/netmonitor/src/constants.js
@@ -153,16 +153,32 @@ const HEADERS = [
     canFilter: true,
   },
   {
     name: "waterfall",
     canFilter: false,
   }
 ];
 
+const HEADER_FILTERS = HEADERS
+  .filter(h => h.canFilter)
+  .map(h => h.filterKey || h.name);
+
+const FILTER_FLAGS = [
+  ...HEADER_FILTERS,
+  "set-cookie-domain",
+  "set-cookie-name",
+  "set-cookie-value",
+  "mime-type",
+  "larger-than",
+  "is",
+  "has-response-header",
+  "regexp",
+];
+
 const REQUESTS_WATERFALL = {
   BACKGROUND_TICKS_MULTIPLE: 5, // ms
   BACKGROUND_TICKS_SCALES: 3,
   BACKGROUND_TICKS_SPACING_MIN: 10, // px
   BACKGROUND_TICKS_COLOR_RGB: [128, 136, 144],
   // 8-bit value of the alpha component of the tick color
   BACKGROUND_TICKS_OPACITY_MIN: 32,
   BACKGROUND_TICKS_OPACITY_ADD: 32,
@@ -175,14 +191,15 @@ const REQUESTS_WATERFALL = {
   LABEL_WIDTH: 50, // px
 };
 
 const general = {
   ACTIVITY_TYPE,
   EVENTS,
   FILTER_SEARCH_DELAY: 200,
   HEADERS,
+  FILTER_FLAGS,
   SOURCE_EDITOR_SYNTAX_HIGHLIGHT_MAX_SIZE: 51200, // 50 KB in bytes
   REQUESTS_WATERFALL,
 };
 
 // flatten constants
 module.exports = Object.assign({}, general, actionTypes);
--- a/devtools/client/netmonitor/src/utils/filter-text-utils.js
+++ b/devtools/client/netmonitor/src/utils/filter-text-utils.js
@@ -25,33 +25,18 @@
  * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
  * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
  * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  */
 
 "use strict";
 
-const { HEADERS } = require("../constants");
+const { FILTER_FLAGS } = require("../constants");
 const { getFormattedIPAndPort } = require("./format-utils");
-const HEADER_FILTERS = HEADERS
-  .filter(h => h.canFilter)
-  .map(h => h.filterKey || h.name);
-
-const FILTER_FLAGS = [
-  ...HEADER_FILTERS,
-  "set-cookie-domain",
-  "set-cookie-name",
-  "set-cookie-value",
-  "mime-type",
-  "larger-than",
-  "is",
-  "has-response-header",
-  "regexp",
-];
 
 /*
   The function `parseFilters` is from:
   https://github.com/ChromeDevTools/devtools-frontend/
 
   front_end/network/FilterSuggestionBuilder.js#L138-L163
   Commit f340aefd7ec9b702de9366a812288cfb12111fce
 */
--- a/devtools/client/netmonitor/webpack.config.js
+++ b/devtools/client/netmonitor/webpack.config.js
@@ -37,16 +37,17 @@ let webpackConfig = {
   resolve: {
     fallback: path.join(__dirname, "node_modules"),
     alias: {
       "react": path.join(__dirname, "node_modules/react"),
       "devtools/client/framework/devtools": path.join(__dirname, "../../client/shims/devtools"),
       "devtools/client/framework/menu": "devtools-modules/src/menu",
       "devtools/client/framework/menu-item": path.join(__dirname, "../../client/framework/menu-item"),
       "devtools/client/locales": path.join(__dirname, "../../client/locales/en-US"),
+      "devtools/client/shared/components/autocomplete-popup": path.join(__dirname, "../../client/shared/components/autocomplete-popup"),
       "devtools/client/shared/components/reps/reps": path.join(__dirname, "../../client/shared/components/reps/reps"),
       "devtools/client/shared/components/search-box": path.join(__dirname, "../../client/shared/components/search-box"),
       "devtools/client/shared/components/splitter/draggable": path.join(__dirname, "../../client/shared/components/splitter/draggable"),
       "devtools/client/shared/components/splitter/split-box": path.join(__dirname, "../../client/shared/components/splitter/split-box"),
       "devtools/client/shared/components/stack-trace": path.join(__dirname, "../../client/shared/components/stack-trace"),
       "devtools/client/shared/components/tabs/tabbar": path.join(__dirname, "../../client/shared/components/tabs/tabbar"),
       "devtools/client/shared/components/tabs/tabs": path.join(__dirname, "../../client/shared/components/tabs/tabs"),
       "devtools/client/shared/components/tree/tree-view": path.join(__dirname, "../../client/shared/components/tree/tree-view"),
new file mode 100644
--- /dev/null
+++ b/devtools/client/shared/components/autocomplete-popup.js
@@ -0,0 +1,122 @@
+/* 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 { DOM: dom, createClass, PropTypes } = require("devtools/client/shared/vendor/react");
+
+module.exports = createClass({
+  displayName: "AutocompletePopup",
+
+  propTypes: {
+    list: PropTypes.array.isRequired,
+    filter: PropTypes.string.isRequired,
+    onItemSelected: PropTypes.func.isRequired,
+  },
+
+  getInitialState() {
+    return this.computeState(this.props);
+  },
+
+  componentWillReceiveProps(nextProps) {
+    if (this.props.filter === nextProps.filter) {
+      return;
+    }
+    this.setState(this.computeState(nextProps));
+  },
+
+  componentDidUpdate() {
+    if (this.refs.selected) {
+      this.refs.selected.scrollIntoView(false);
+    }
+  },
+
+  computeState({ filter, list }) {
+    let filteredList = list.filter((item) => {
+      return item.toLowerCase().startsWith(filter.toLowerCase())
+        && item.toLowerCase() !== filter.toLowerCase();
+    }).sort();
+    let selectedIndex = filteredList.length == 1 ? 0 : -1;
+
+    return { filteredList, selectedIndex };
+  },
+
+  /**
+   * Use this method to select the top-most item
+   * This method is public, called outside of the autocomplete-popup component.
+   */
+  jumpToTop() {
+    this.setState({ selectedIndex: 0 });
+  },
+
+  /**
+   * Use this method to select the bottom-most item
+   * This method is public.
+   */
+  jumpToBottom() {
+    let selectedIndex = this.state.filteredList.length - 1;
+    this.setState({ selectedIndex });
+  },
+
+  /**
+   * Increment the selected index with the provided increment value. Will cycle to the
+   * beginning/end of the list if the index exceeds the list boundaries.
+   * This method is public.
+   *
+   * @param {number} increment - No. of hops in the direction
+   */
+  jumpBy(increment = 1) {
+    let { filteredList, selectedIndex } = this.state;
+    let nextIndex = selectedIndex + increment;
+    if (increment > 0) {
+      // Positive cycling
+      nextIndex = nextIndex > filteredList.length - 1 ? 0 : nextIndex;
+    } else if (increment < 0) {
+      // Inverse cycling
+      nextIndex = nextIndex < 0 ? filteredList.length - 1 : nextIndex;
+    }
+    this.setState({selectedIndex: nextIndex});
+  },
+
+  /**
+   * Submit the currently selected item to the onItemSelected callback
+   * This method is public.
+   */
+  select() {
+    if (this.refs.selected) {
+      this.props.onItemSelected(this.refs.selected.textContent);
+    }
+  },
+
+  onMouseDown(e) {
+    e.preventDefault();
+    this.setState({ selectedIndex: Number(e.target.dataset.index) }, this.select);
+  },
+
+  render() {
+    let { filteredList } = this.state;
+
+    return filteredList.length > 0 && dom.div(
+      { className: "devtools-autocomplete-popup devtools-monospace" },
+      dom.ul(
+        { className: "devtools-autocomplete-listbox" },
+        filteredList.map((item, i) => {
+          let isSelected = this.state.selectedIndex == i;
+          let itemClassList = ["autocomplete-item"];
+
+          if (isSelected) {
+            itemClassList.push("autocomplete-selected");
+          }
+          return dom.li({
+            key: i,
+            "data-index": i,
+            className: itemClassList.join(" "),
+            ref: isSelected ? "selected" : null,
+            onMouseDown: this.onMouseDown,
+          }, item);
+        })
+      )
+    );
+  }
+});
--- a/devtools/client/shared/components/moz.build
+++ b/devtools/client/shared/components/moz.build
@@ -7,16 +7,17 @@
 DIRS += [
     'reps',
     'splitter',
     'tabs',
     'tree'
 ]
 
 DevToolsModules(
+    'autocomplete-popup.js',
     'frame.js',
     'h-split-box.js',
     'notification-box.css',
     'notification-box.js',
     'search-box.js',
     'sidebar-toggle.css',
     'sidebar-toggle.js',
     'stack-trace.js',
--- a/devtools/client/shared/components/search-box.js
+++ b/devtools/client/shared/components/search-box.js
@@ -1,36 +1,45 @@
 /* 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/. */
 
 /* global window */
 
 "use strict";
 
-const { DOM: dom, createClass, PropTypes } = require("devtools/client/shared/vendor/react");
+const { DOM: dom, createClass, PropTypes, createFactory } = require("devtools/client/shared/vendor/react");
 const KeyShortcuts = require("devtools/client/shared/key-shortcuts");
+const AutocompletePopup = createFactory(require("devtools/client/shared/components/autocomplete-popup"));
 
 /**
  * A generic search box component for use across devtools
  */
 module.exports = createClass({
   displayName: "SearchBox",
 
   propTypes: {
     delay: PropTypes.number,
     keyShortcut: PropTypes.string,
     onChange: PropTypes.func,
     placeholder: PropTypes.string,
-    type: PropTypes.string
+    type: PropTypes.string,
+    autocompleteList: PropTypes.array,
+  },
+
+  getDefaultProps() {
+    return {
+      autocompleteList: [],
+    };
   },
 
   getInitialState() {
     return {
-      value: ""
+      value: "",
+      focused: false,
     };
   },
 
   componentDidMount() {
     if (!this.props.keyShortcut) {
       return;
     }
 
@@ -51,17 +60,19 @@ module.exports = createClass({
     // Clean up an existing timeout.
     if (this.searchTimeout) {
       clearTimeout(this.searchTimeout);
     }
   },
 
   onChange() {
     if (this.state.value !== this.refs.input.value) {
-      this.setState({ value: this.refs.input.value });
+      this.setState({
+        value: this.refs.input.value,
+      });
     }
 
     if (!this.props.delay) {
       this.props.onChange(this.state.value);
       return;
     }
 
     // Clean up an existing timeout before creating a new one.
@@ -77,34 +88,98 @@ module.exports = createClass({
     }, this.props.delay);
   },
 
   onClearButtonClick() {
     this.refs.input.value = "";
     this.onChange();
   },
 
+  onFocus() {
+    this.setState({ focused: true });
+  },
+
+  onBlur() {
+    this.setState({ focused: false });
+  },
+
+  onKeyDown(e) {
+    let { autocompleteList } = this.props;
+    let { autocomplete } = this.refs;
+
+    if (autocompleteList.length == 0) {
+      return;
+    }
+
+    switch (e.key) {
+      case "ArrowDown":
+        autocomplete.jumpBy(1);
+        break;
+      case "ArrowUp":
+        autocomplete.jumpBy(-1);
+        break;
+      case "PageDown":
+        autocomplete.jumpBy(5);
+        break;
+      case "PageUp":
+        autocomplete.jumpBy(-5);
+        break;
+      case "Enter":
+      case "Tab":
+        e.preventDefault();
+        autocomplete.select();
+        break;
+      case "Escape":
+        e.preventDefault();
+        this.onBlur();
+        break;
+      case "Home":
+        autocomplete.jumpToTop();
+        break;
+      case "End":
+        autocomplete.jumpToBottom();
+        break;
+    }
+  },
+
   render() {
-    let { type = "search", placeholder } = this.props;
+    let {
+      type = "search",
+      placeholder,
+      autocompleteList
+    } = this.props;
     let { value } = this.state;
     let divClassList = ["devtools-searchbox", "has-clear-btn"];
     let inputClassList = [`devtools-${type}input`];
 
     if (value !== "") {
       inputClassList.push("filled");
     }
     return dom.div(
       { className: divClassList.join(" ") },
       dom.input({
         className: inputClassList.join(" "),
         onChange: this.onChange,
+        onFocus: this.onFocus,
+        onBlur: this.onBlur,
+        onKeyDown: this.onKeyDown,
         placeholder,
         ref: "input",
-        value
+        value,
       }),
       dom.button({
         className: "devtools-searchinput-clear",
         hidden: value == "",
         onClick: this.onClearButtonClick
+      }),
+      autocompleteList.length > 0 && this.state.focused &&
+      AutocompletePopup({
+        list: autocompleteList,
+        filter: value,
+        ref: "autocomplete",
+        onItemSelected: (itemValue) => {
+          this.setState({ value: itemValue });
+          this.onChange();
+        }
       })
     );
   }
 });
--- a/devtools/client/themes/common.css
+++ b/devtools/client/themes/common.css
@@ -77,100 +77,86 @@ html|button, html|select {
   padding: 2px;
 }
 
 .devtools-autocomplete-listbox .autocomplete-item {
   width: 100%;
   background-color: transparent;
   border-radius: 4px;
   padding: 1px 0;
-}
-
-.devtools-autocomplete-listbox .autocomplete-selected {
-  background-color: rgba(0,0,0,0.2);
-}
-
-.devtools-autocomplete-listbox.dark-theme .autocomplete-selected,
-.devtools-autocomplete-listbox.dark-theme .autocomplete-item:hover {
-  background-color: rgba(0,0,0,0.5);
-}
-
-.devtools-autocomplete-listbox.dark-theme .autocomplete-selected > .autocomplete-value,
-.devtools-autocomplete-listbox:focus.dark-theme .autocomplete-selected > .initial-value {
-  color: hsl(208,100%,60%);
-}
-
-.devtools-autocomplete-listbox.dark-theme .autocomplete-selected > span {
-  color: #eee;
-}
-
-.devtools-autocomplete-listbox.dark-theme .autocomplete-item > span {
-  color: #ccc;
+  cursor: default;
 }
 
 .devtools-autocomplete-listbox .autocomplete-item > .initial-value,
 .devtools-autocomplete-listbox .autocomplete-item > .autocomplete-value {
   margin: 0;
   padding: 0;
-  cursor: default;
 }
 
 .devtools-autocomplete-listbox .autocomplete-item > .autocomplete-count {
   text-align: end;
 }
 
 /* Rest of the dark and light theme */
 
 .devtools-autocomplete-popup,
+.CodeMirror-hints,
+.CodeMirror-Tern-tooltip {
+  border: 1px solid hsl(210,24%,90%);
+  background-image: linear-gradient(to bottom, hsla(209,18%,100%,0.9), hsl(210,24%,95%));
+  box-shadow: 0 1px 0 hsla(209,29%,90%,.25) inset;
+}
+
+.theme-dark .devtools-autocomplete-popup,
 .theme-dark .CodeMirror-hints,
 .theme-dark .CodeMirror-Tern-tooltip {
   border: 1px solid hsl(210,11%,10%);
   background-image: linear-gradient(to bottom, hsla(209,18%,18%,0.9), hsl(210,11%,16%));
-}
-
-.devtools-autocomplete-popup.light-theme,
-.light-theme .CodeMirror-hints,
-.light-theme .CodeMirror-Tern-tooltip {
-  border: 1px solid hsl(210,24%,90%);
-  background-image: linear-gradient(to bottom, hsla(209,18%,100%,0.9), hsl(210,24%,95%));
-}
-
-.devtools-autocomplete-popup.light-theme {
-  box-shadow: 0 1px 0 hsla(209,29%,90%,.25) inset;
+  box-shadow: none;
 }
 
 .theme-firebug .devtools-autocomplete-popup {
   border-color: var(--theme-splitter-color);
   border-radius: 5px;
   font-size: var(--theme-autompletion-font-size);
-}
-
-.devtools-autocomplete-popup.firebug-theme {
   background: var(--theme-body-background);
 }
 
-.devtools-autocomplete-listbox.firebug-theme .autocomplete-selected,
-.devtools-autocomplete-listbox.firebug-theme .autocomplete-item:hover,
-.devtools-autocomplete-listbox.light-theme .autocomplete-selected,
-.devtools-autocomplete-listbox.light-theme .autocomplete-item:hover {
+.devtools-autocomplete-listbox .autocomplete-selected,
+.devtools-autocomplete-listbox .autocomplete-item:hover {
   background-color: rgba(128,128,128,0.3);
 }
 
-.devtools-autocomplete-listbox.firebug-theme .autocomplete-selected > .autocomplete-value,
-.devtools-autocomplete-listbox:focus.firebug-theme .autocomplete-selected > .initial-value,
-.devtools-autocomplete-listbox.light-theme .autocomplete-selected > .autocomplete-value,
-.devtools-autocomplete-listbox:focus.light-theme .autocomplete-selected > .initial-value {
+.theme-dark .devtools-autocomplete-listbox .autocomplete-selected,
+.theme-dark .devtools-autocomplete-listbox .autocomplete-item:hover {
+  background-color: rgba(0,0,0,0.5);
+}
+
+.devtools-autocomplete-listbox .autocomplete-selected > .autocomplete-value,
+.devtools-autocomplete-listbox:focus .autocomplete-selected > .initial-value {
   color: #222;
 }
 
-.devtools-autocomplete-listbox.firebug-theme .autocomplete-item > span,
-.devtools-autocomplete-listbox.light-theme .autocomplete-item > span {
+.theme-dark .devtools-autocomplete-listbox .autocomplete-selected > .autocomplete-value,
+.theme-dark .devtools-autocomplete-listbox:focus .autocomplete-selected > .initial-value {
+  color: hsl(208,100%,60%);
+}
+
+.devtools-autocomplete-listbox .autocomplete-item > span {
   color: #666;
 }
 
+.theme-dark .devtools-autocomplete-listbox .autocomplete-item > span {
+  color: #ccc;
+}
+
+.theme-dark .devtools-autocomplete-listbox .autocomplete-selected > span {
+  color: #eee;
+}
+
 /* Autocomplete list clone used for accessibility. */
 
 .devtools-autocomplete-list-aria-clone {
   /* Cannot use display:none or visibility:hidden : screen readers ignore the element. */
   position: fixed;
   overflow: hidden;
   margin: 0;
   width: 0;
@@ -488,16 +474,24 @@ checkbox:-moz-focusring {
 .devtools-searchinput:focus,
 .devtools-filterinput:focus {
   border-color: var(--theme-focus-border-color-textbox);
   box-shadow: var(--theme-focus-box-shadow-textbox);
   transition: all 0.2s ease-in-out;
   outline: none;
 }
 
+.devtools-searchbox .devtools-autocomplete-popup {
+  position: absolute;
+  top: 100%;
+  width: 100%;
+  line-height: initial !important;
+  z-index: 999;
+}
+
 /* Don't add 'double spacing' for inputs that are at beginning / end
    of a toolbar (since the toolbar has it's own spacing). */
 .devtools-toolbar > .devtools-textinput:first-child,
 .devtools-toolbar > .devtools-searchinput:first-child,
 .devtools-toolbar > .devtools-filterinput:first-child {
   margin-inline-start: 0;
 }
 .devtools-toolbar > .devtools-textinput:last-child,