Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/framework/mr-header/mr-search-bar.js b/static_src/elements/framework/mr-header/mr-search-bar.js
new file mode 100644
index 0000000..536dfcf
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-search-bar.js
@@ -0,0 +1,501 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import {LitElement, html, css} from 'lit-element';
+import page from 'page';
+import qs from 'qs';
+
+import '../mr-dropdown/mr-dropdown.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import ClientLogger from 'monitoring/client-logger';
+import {issueRefToUrl} from 'shared/convertersV0.js';
+
+// Search field input regex testing for all digits
+// indicating that the user wants to jump to the specified issue.
+const JUMP_RE = /^\d+$/;
+
+/**
+ * `<mr-search-bar>`
+ *
+ * The searchbar for Monorail.
+ *
+ */
+export class MrSearchBar extends LitElement {
+  /** @override */
+  static get styles() {
+    return css`
+      :host {
+        --mr-search-bar-background: var(--chops-white);
+        --mr-search-bar-border-radius: 4px;
+        --mr-search-bar-border: var(--chops-normal-border);
+        --mr-search-bar-chip-color: var(--chops-gray-200);
+        height: 30px;
+        font-size: var(--chops-large-font-size);
+      }
+      input#searchq {
+        display: flex;
+        align-items: center;
+        justify-content: flex-start;
+        flex-grow: 2;
+        min-width: 100px;
+        border: none;
+        border-top: var(--mr-search-bar-border);
+        border-bottom: var(--mr-search-bar-border);
+        background: var(--mr-search-bar-background);
+        height: 100%;
+        box-sizing: border-box;
+        padding: 0 2px;
+        font-size: inherit;
+      }
+      mr-dropdown {
+        text-align: right;
+        display: flex;
+        text-overflow: ellipsis;
+        box-sizing: border-box;
+        background: var(--mr-search-bar-background);
+        border: var(--mr-search-bar-border);
+        border-left: 0;
+        border-radius: 0 var(--mr-search-bar-border-radius)
+          var(--mr-search-bar-border-radius) 0;
+        height: 100%;
+        align-items: center;
+        justify-content: center;
+        text-decoration: none;
+      }
+      button {
+        font-size: inherit;
+        order: -1;
+        background: var(--mr-search-bar-background);
+        cursor: pointer;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        height: 100%;
+        box-sizing: border-box;
+        border: var(--mr-search-bar-border);
+        border-left: none;
+        border-right: none;
+        padding: 0 8px;
+      }
+      form {
+        display: flex;
+        height: 100%;
+        width: 100%;
+        align-items: center;
+        justify-content: flex-start;
+        flex-direction: row;
+      }
+      i.material-icons {
+        font-size: var(--chops-icon-font-size);
+        color: var(--chops-primary-icon-color);
+      }
+      .select-container {
+        order: -2;
+        max-width: 150px;
+        min-width: 50px;
+        flex-shrink: 1;
+        height: 100%;
+        position: relative;
+        box-sizing: border-box;
+        border: var(--mr-search-bar-border);
+        border-radius: var(--mr-search-bar-border-radius) 0 0
+          var(--mr-search-bar-border-radius);
+        background: var(--mr-search-bar-chip-color);
+      }
+      .select-container i.material-icons {
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        position: absolute;
+        right: 0;
+        top: 0;
+        height: 100%;
+        width: 20px;
+        z-index: 2;
+        padding: 0;
+      }
+      select {
+        color: var(--chops-primary-font-color);
+        display: flex;
+        align-items: center;
+        justify-content: flex-start;
+        -webkit-appearance: none;
+        -moz-appearance: none;
+        appearance: none;
+        text-overflow: ellipsis;
+        cursor: pointer;
+        width: 100%;
+        height: 100%;
+        background: none;
+        margin: 0;
+        padding: 0 20px 0 8px;
+        box-sizing: border-box;
+        border: 0;
+        z-index: 3;
+        font-size: inherit;
+        position: relative;
+      }
+      select::-ms-expand {
+        display: none;
+      }
+      select::after {
+        position: relative;
+        right: 0;
+        content: 'arrow_drop_down';
+        font-family: 'Material Icons';
+      }
+    `;
+  }
+
+  /** @override */
+  render() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <form
+        @submit=${this._submitSearch}
+        @keypress=${this._submitSearchWithKeypress}
+      >
+        ${this._renderSearchScopeSelector()}
+        <input
+          id="searchq"
+          type="text"
+          name="q"
+          placeholder="Search ${this.projectName} issues..."
+          .value=${this.initialQuery || ''}
+          autocomplete="off"
+          aria-label="Search box"
+          @focus=${this._searchEditStarted}
+          @blur=${this._searchEditFinished}
+          spellcheck="false"
+        />
+        <button type="submit">
+          <i class="material-icons">search</i>
+        </button>
+        <mr-dropdown
+          label="Search options"
+          .items=${this._searchMenuItems}
+        ></mr-dropdown>
+      </form>
+    `;
+  }
+
+  /**
+   * Render helper for the select menu that lets user select which search
+   * context/saved query they want to use.
+   * @return {TemplateResult}
+   */
+  _renderSearchScopeSelector() {
+    return html`
+      <div class="select-container">
+        <i class="material-icons" role="presentation">arrow_drop_down</i>
+        <select
+          id="can"
+          name="can"
+          @change=${this._redirectOnSelect}
+          aria-label="Search scope"
+        >
+          <optgroup label="Search within">
+            <option
+              value="1"
+              ?selected=${this.initialCan === '1'}
+            >All issues</option>
+            <option
+              value="2"
+              ?selected=${this.initialCan === '2'}
+            >Open issues</option>
+            <option
+              value="3"
+              ?selected=${this.initialCan === '3'}
+            >Open and owned by me</option>
+            <option
+              value="4"
+              ?selected=${this.initialCan === '4'}
+            >Open and reported by me</option>
+            <option
+              value="5"
+              ?selected=${this.initialCan === '5'}
+            >Open and starred by me</option>
+            <option
+              value="8"
+              ?selected=${this.initialCan === '8'}
+            >Open with comment by me</option>
+            <option
+              value="6"
+              ?selected=${this.initialCan === '6'}
+            >New issues</option>
+            <option
+              value="7"
+              ?selected=${this.initialCan === '7'}
+            >Issues to verify</option>
+          </optgroup>
+          <optgroup label="Project queries" ?hidden=${!this.userDisplayName}>
+            ${this._renderSavedQueryOptions(this.projectSavedQueries, 'project-query')}
+            <option data-href="/p/${this.projectName}/adminViews">
+              Manage project queries...
+            </option>
+          </optgroup>
+          <optgroup label="My saved queries" ?hidden=${!this.userDisplayName}>
+            ${this._renderSavedQueryOptions(this.userSavedQueries, 'user-query')}
+            <option data-href="/u/${this.userDisplayName}/queries">
+              Manage my saved queries...
+            </option>
+          </optgroup>
+        </select>
+      </div>
+    `;
+  }
+
+  /**
+   * Render helper for adding saved queries to the search scope select.
+   * @param {Array<SavedQuery>} queries Queries to render.
+   * @param {string} className CSS class to be applied to each option.
+   * @return {Array<TemplateResult>}
+   */
+  _renderSavedQueryOptions(queries, className) {
+    if (!queries) return;
+    return queries.map((query) => html`
+      <option
+        class=${className}
+        value=${query.queryId}
+        ?selected=${this.initialCan === query.queryId}
+      >${query.name}</option>
+    `);
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      projectName: {type: String},
+      userDisplayName: {type: String},
+      initialCan: {type: String},
+      initialQuery: {type: String},
+      projectSavedQueries: {type: Array},
+      userSavedQueries: {type: Array},
+      queryParams: {type: Object},
+      keptQueryParams: {type: Array},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+    this.queryParams = {};
+    this.keptQueryParams = [
+      'sort',
+      'groupby',
+      'colspec',
+      'x',
+      'y',
+      'mode',
+      'cells',
+      'num',
+    ];
+    this.initialQuery = '';
+    this.initialCan = '2';
+    this.projectSavedQueries = [];
+    this.userSavedQueries = [];
+
+    this.clientLogger = new ClientLogger('issues');
+
+    this._page = page;
+  }
+
+  /** @override */
+  connectedCallback() {
+    super.connectedCallback();
+
+    // Global event listeners. Make sure to unbind these when the
+    // element disconnects.
+    this._boundFocus = this.focus.bind(this);
+    window.addEventListener('focus-search', this._boundFocus);
+  }
+
+  /** @override */
+  disconnectedCallback() {
+    super.disconnectedCallback();
+
+    window.removeEventListener('focus-search', this._boundFocus);
+  }
+
+  /** @override */
+  updated(changedProperties) {
+    if (this.userDisplayName && changedProperties.has('userDisplayName')) {
+      const userSavedQueriesPromise = prpcClient.call('monorail.Users',
+          'GetSavedQueries', {});
+      userSavedQueriesPromise.then((resp) => {
+        this.userSavedQueries = resp.savedQueries;
+      });
+    }
+  }
+
+  /**
+   * Sends an event to ClientLogger describing that the user started typing
+   * a search query.
+   */
+  _searchEditStarted() {
+    this.clientLogger.logStart('query-edit', 'user-time');
+    this.clientLogger.logStart('issue-search', 'user-time');
+  }
+
+  /**
+   * Sends an event to ClientLogger saying that the user finished typing a
+   * search.
+   */
+  _searchEditFinished() {
+    this.clientLogger.logEnd('query-edit');
+  }
+
+  /**
+   * On Shift+Enter, this handler opens the search in a new tab.
+   * @param {KeyboardEvent} e
+   */
+  _submitSearchWithKeypress(e) {
+    if (e.key === 'Enter' && (e.shiftKey)) {
+      const form = e.currentTarget;
+      this._runSearch(form, true);
+    }
+    // In all other cases, we want to let the submit handler do the work.
+    // ie: pressing 'Enter' on a form should natively open it in a new tab.
+  }
+
+  /**
+   * Update the URL on form submit.
+   * @param {Event} e
+   */
+  _submitSearch(e) {
+    e.preventDefault();
+
+    const form = e.target;
+    this._runSearch(form);
+  }
+
+  /**
+   * Updates the URL with the new search set in the query string.
+   * @param {HTMLFormElement} form the native form element to submit.
+   * @param {boolean=} newTab whether to open the search in a new tab.
+   */
+  _runSearch(form, newTab) {
+    this.clientLogger.logEnd('query-edit');
+    this.clientLogger.logPause('issue-search', 'user-time');
+    this.clientLogger.logStart('issue-search', 'computer-time');
+
+    const params = {};
+
+    this.keptQueryParams.forEach((param) => {
+      if (param in this.queryParams) {
+        params[param] = this.queryParams[param];
+      }
+    });
+
+    params.q = form.q.value.trim();
+    params.can = form.can.value;
+
+    this._navigateToNext(params, newTab);
+  }
+
+  /**
+   * Attempt to jump-to-issue, otherwise continue to list view
+   * @param {Object} params URL navigation parameters
+   * @param {boolean} newTab
+   */
+  async _navigateToNext(params, newTab = false) {
+    let resp;
+    if (JUMP_RE.test(params.q)) {
+      const message = {
+        issueRef: {
+          projectName: this.projectName,
+          localId: params.q,
+        },
+      };
+
+      try {
+        resp = await prpcClient.call(
+            'monorail.Issues', 'GetIssue', message,
+        );
+      } catch (error) {
+        // Fall through to navigateToList
+      }
+    }
+    if (resp && resp.issue) {
+      const link = issueRefToUrl(resp.issue, params);
+      this._page(link);
+    } else {
+      this._navigateToList(params, newTab);
+    }
+  }
+
+  /**
+   * Navigate to list view, currently splits on old and new view
+   * @param {Object} params URL navigation parameters
+   * @param {boolean} newTab
+   * @fires Event#refreshList
+   * @private
+   */
+  _navigateToList(params, newTab = false) {
+    const pathname = `/p/${this.projectName}/issues/list`;
+
+    const hasChanges = !window.location.pathname.startsWith(pathname) ||
+      this.queryParams.q !== params.q ||
+      this.queryParams.can !== params.can;
+
+    const url =`${pathname}?${qs.stringify(params)}`;
+
+    if (newTab) {
+      window.open(url, '_blank', 'noopener');
+    } else if (hasChanges) {
+      this._page(url);
+    } else {
+      // TODO(zhangtiff): Replace this event with Redux once all of Monorail
+      // uses Redux.
+      // This is needed because navigating to the exact same page does not
+      // cause a URL change to happen.
+      this.dispatchEvent(new Event('refreshList',
+          {'composed': true, 'bubbles': true}));
+    }
+  }
+
+  /**
+   * Wrap the native focus() function for the search form to allow parent
+   * elements to focus the search.
+   */
+  focus() {
+    const search = this.shadowRoot.querySelector('#searchq');
+    search.focus();
+  }
+
+  /**
+   * Populates the search dropdown.
+   * @return {Array<MenuItem>}
+   */
+  get _searchMenuItems() {
+    const projectName = this.projectName;
+    return [
+      {
+        text: 'Advanced search',
+        url: `/p/${projectName}/issues/advsearch`,
+      },
+      {
+        text: 'Search tips',
+        url: `/p/${projectName}/issues/searchtips`,
+      },
+    ];
+  }
+
+  /**
+   * The search dropdown includes links like "Manage my saved queries..."
+   * that automatically navigate a user to a new page when they select those
+   * options.
+   * @param {Event} evt
+   */
+  _redirectOnSelect(evt) {
+    const target = evt.target;
+    const option = target.options[target.selectedIndex];
+
+    if (option.dataset.href) {
+      this._page(option.dataset.href);
+    }
+  }
+}
+
+customElements.define('mr-search-bar', MrSearchBar);