Project import generated by Copybara.

GitOrigin-RevId: d9e9e3fb4e31372ec1fb43b178994ca78fa8fe70
diff --git a/static_src/elements/framework/mr-header/mr-header.js b/static_src/elements/framework/mr-header/mr-header.js
new file mode 100644
index 0000000..6603c85
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-header.js
@@ -0,0 +1,427 @@
+// 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 {connectStore} from 'reducers/base.js';
+import * as userV0 from 'reducers/userV0.js';
+import * as projectV0 from 'reducers/projectV0.js';
+import * as sitewide from 'reducers/sitewide.js';
+
+import {prpcClient} from 'prpc-client-instance.js';
+import 'elements/framework/mr-keystrokes/mr-keystrokes.js';
+import '../mr-dropdown/mr-dropdown.js';
+import '../mr-dropdown/mr-account-dropdown.js';
+import './mr-search-bar.js';
+
+import {SHARED_STYLES} from 'shared/shared-styles.js';
+
+import {logEvent} from 'monitoring/client-logger.js';
+
+/**
+ * @type {Object<string, string>} JS coding of enum values from
+ *    appengine/monorail/api/v3/api_proto/project_objects.proto.
+ */
+const projectRoles = Object.freeze({
+  OWNER: 'Owner',
+  MEMBER: 'Member',
+  CONTRIBUTOR: 'Contributor',
+  NONE: '',
+});
+
+/**
+ * `<mr-header>`
+ *
+ * The header for Monorail.
+ *
+ */
+export class MrHeader extends connectStore(LitElement) {
+  /** @override */
+  static get styles() {
+    return [
+      SHARED_STYLES,
+      css`
+        :host {
+          color: var(--chops-header-text-color);
+          box-sizing: border-box;
+          background: hsl(221, 67%, 92%);
+          width: 100%;
+          height: var(--monorail-header-height);
+          display: flex;
+          flex-direction: row;
+          justify-content: flex-start;
+          align-items: center;
+          z-index: 800;
+          background-color: var(--chops-primary-header-bg);
+          border-bottom: var(--chops-normal-border);
+          top: 0;
+          position: fixed;
+          padding: 0 4px;
+          font-size: var(--chops-large-font-size);
+        }
+        @media (max-width: 840px) {
+          :host {
+            position: static;
+          }
+        }
+        a {
+          font-size: inherit;
+          color: var(--chops-link-color);
+          text-decoration: none;
+          display: flex;
+          align-items: center;
+          justify-content: center;
+          height: 100%;
+          padding: 0 4px;
+          flex-grow: 0;
+          flex-shrink: 0;
+        }
+        a[hidden] {
+          display: none;
+        }
+        a.button {
+          font-size: inherit;
+          height: auto;
+          margin: 0 8px;
+          border: 0;
+          height: 30px;
+        }
+        .home-link {
+          color: var(--chops-gray-900);
+          letter-spacing: 0.5px;
+          font-size: 18px;
+          font-weight: 400;
+          display: flex;
+          font-stretch: 100%;
+          padding-left: 8px;
+        }
+        a.home-link img {
+          /** Cover up default padding with the custom logo. */
+          margin-left: -8px;
+        }
+        a.home-link:hover {
+          text-decoration: none;
+        }
+        mr-search-bar {
+          margin-left: 8px;
+          flex-grow: 2;
+          max-width: 1000px;
+        }
+        i.material-icons {
+          font-size: var(--chops-icon-font-size);
+          color: var(--chops-primary-icon-color);
+        }
+        i.material-icons[hidden] {
+          display: none;
+        }
+        .right-section {
+          font-size: inherit;
+          display: flex;
+          align-items: center;
+          height: 100%;
+          margin-left: auto;
+          justify-content: flex-end;
+        }
+        .hamburger-icon:hover {
+          text-decoration: none;
+        }
+      `,
+    ];
+  }
+
+  /** @override */
+  render() {
+    return this.projectName ?
+        this._renderProjectScope() : this._renderNonProjectScope();
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderProjectScope() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <mr-keystrokes
+        .issueId=${this.queryParams.id}
+        .queryParams=${this.queryParams}
+        .issueEntryUrl=${this.issueEntryUrl}
+      ></mr-keystrokes>
+      <a href="/p/${this.projectName}/issues/list" class="home-link">
+        ${this.projectThumbnailUrl ? html`
+          <img
+            class="project-logo"
+            src=${this.projectThumbnailUrl}
+            title=${this.projectName}
+          />
+        ` : this.projectName}
+      </a>
+      <mr-dropdown
+        class="project-selector"
+        .text=${this.projectName}
+        .items=${this._projectDropdownItems}
+        menuAlignment="left"
+        title=${this.presentationConfig.projectSummary}
+      ></mr-dropdown>
+      <a class="button emphasized new-issue-link" href=${this.issueEntryUrl}>
+        New issue
+      </a>
+      <mr-search-bar
+        .projectName=${this.projectName}
+        .userDisplayName=${this.userDisplayName}
+        .projectSavedQueries=${this.presentationConfig.savedQueries}
+        .initialCan=${this._currentCan}
+        .initialQuery=${this._currentQuery}
+        .queryParams=${this.queryParams}
+      ></mr-search-bar>
+
+      <div class="right-section">
+        <mr-dropdown
+          icon="settings"
+          label="Project Settings"
+          .items=${this._projectSettingsItems}
+        ></mr-dropdown>
+
+        ${this._renderAccount()}
+      </div>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderNonProjectScope() {
+    return html`
+      <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
+      <a class="hamburger-icon" title="Main menu" hidden>
+        <i class="material-icons">menu</i>
+      </a>
+      ${this._headerTitle ?
+          html`<span class="home-link">${this._headerTitle}</span>` :
+          html`<a href="/" class="home-link">Monorail</a>`}
+
+      <div class="right-section">
+        ${this._renderAccount()}
+      </div>
+    `;
+  }
+
+  /**
+   * @return {TemplateResult}
+   */
+  _renderAccount() {
+    if (!this.userDisplayName) {
+      return html`<a href=${this.loginUrl}>Sign in</a>`;
+    }
+
+    return html`
+      <mr-account-dropdown
+        .userDisplayName=${this.userDisplayName}
+        .logoutUrl=${this.logoutUrl}
+        .loginUrl=${this.loginUrl}
+      ></mr-account-dropdown>
+    `;
+  }
+
+  /** @override */
+  static get properties() {
+    return {
+      loginUrl: {type: String},
+      logoutUrl: {type: String},
+      projectName: {type: String},
+      // Project thumbnail is set separately from presentationConfig to prevent
+      // "flashing" logo when navigating EZT pages.
+      projectThumbnailUrl: {type: String},
+      userDisplayName: {type: String},
+      isSiteAdmin: {type: Boolean},
+      userProjects: {type: Object},
+      presentationConfig: {type: Object},
+      queryParams: {type: Object},
+      // TODO(zhangtiff): Change this to be dynamically computed by the
+      //   frontend with logic similar to ComputeIssueEntryURL().
+      issueEntryUrl: {type: String},
+      clientLogger: {type: Object},
+      _headerTitle: {type: String},
+      _currentQuery: {type: String},
+      _currentCan: {type: String},
+    };
+  }
+
+  /** @override */
+  constructor() {
+    super();
+
+    this.presentationConfig = {};
+    this.userProjects = {};
+    this.isSiteAdmin = false;
+
+    this._headerTitle = '';
+  }
+
+  /** @override */
+  stateChanged(state) {
+    this.projectName = projectV0.viewedProjectName(state);
+
+    this.userProjects = userV0.projects(state);
+
+    const currentUser = userV0.currentUser(state);
+    this.isSiteAdmin = currentUser ? currentUser.isSiteAdmin : false;
+
+    const presentationConfig = projectV0.viewedPresentationConfig(state);
+    this.presentationConfig = presentationConfig;
+    // Set separately in order allow EZT pages to load project logo before
+    // the GetPresentationConfig pRPC request.
+    this.projectThumbnailUrl = presentationConfig.projectThumbnailUrl;
+
+    this._headerTitle = sitewide.headerTitle(state);
+
+    this._currentQuery = sitewide.currentQuery(state);
+    this._currentCan = sitewide.currentCan(state);
+
+    this.queryParams = sitewide.queryParams(state);
+  }
+
+  /**
+   * @return {boolean} whether the currently logged in user has admin
+   *   privileges for the currently viewed project.
+   */
+  get canAdministerProject() {
+    if (!this.userDisplayName) return false; // Not logged in.
+    if (this.isSiteAdmin) return true;
+    if (!this.userProjects || !this.userProjects.ownerOf) return false;
+    return this.userProjects.ownerOf.includes(this.projectName);
+  }
+
+  /**
+   * @return {string} The name of the role the user has in the viewed project.
+   */
+  get roleInCurrentProject() {
+    if (!this.userProjects || !this.projectName) return projectRoles.NONE;
+    const {ownerOf = [], memberOf = [], contributorTo = []} = this.userProjects;
+
+    if (ownerOf.includes(this.projectName)) return projectRoles.OWNER;
+    if (memberOf.includes(this.projectName)) return projectRoles.MEMBER;
+    if (contributorTo.includes(this.projectName)) {
+      return projectRoles.CONTRIBUTOR;
+    }
+
+    return projectRoles.NONE;
+  }
+
+  // TODO(crbug.com/monorail/6891): Remove once we deprecate the old issue
+  // filing wizard.
+  /**
+   * @return {string} A URL for the page the issue filing wizard posts to.
+   */
+  get _wizardPostUrl() {
+    // The issue filing wizard posts to the legacy issue entry page's ".do"
+    // endpoint.
+    return `${this._origin}/p/${this.projectName}/issues/entry.do`;
+  }
+
+  /**
+   * @return {string} The domain name of the current page.
+   */
+  get _origin() {
+    return window.location.origin;
+  }
+
+  /**
+   * Computes the URL the user should see to a file an issue, accounting
+   * for the case where a project has a customIssueEntryUrl to navigate to
+   * the wizard as well.
+   * @return {string} The URL that "New issue" button goes to.
+   */
+  get issueEntryUrl() {
+    const config = this.presentationConfig;
+    const role = this.roleInCurrentProject;
+    const mayBeRedirectedToWizard = role === projectRoles.NONE;
+    if (!this.userDisplayName || !config || !config.customIssueEntryUrl ||
+        !mayBeRedirectedToWizard) {
+      return `/p/${this.projectName}/issues/entry`;
+    }
+
+    const token = prpcClient.token;
+
+    const customUrl = this.presentationConfig.customIssueEntryUrl;
+
+    return `${customUrl}?token=${token}&role=${
+      role}&continue=${this._wizardPostUrl}`;
+  }
+
+  /**
+   * @return {Array<MenuItem>} the dropdown items for the project selector,
+   *   showing which projects a user can switch to.
+   */
+  get _projectDropdownItems() {
+    const {userProjects, loginUrl} = this;
+    if (!this.userDisplayName) {
+      return [{text: 'Sign in to see your projects', url: loginUrl}];
+    }
+
+    const items = [];
+    const starredProjects = userProjects.starredProjects || [];
+    const projects = (userProjects.ownerOf || [])
+        .concat(userProjects.memberOf || [])
+        .concat(userProjects.contributorTo || []);
+
+    if (projects.length) {
+      projects.sort();
+      items.push({text: 'My Projects', separator: true});
+
+      projects.forEach((project) => {
+        items.push({text: project, url: `/p/${project}/issues/list`});
+      });
+    }
+
+    if (starredProjects.length) {
+      starredProjects.sort();
+      items.push({text: 'Starred Projects', separator: true});
+
+      starredProjects.forEach((project) => {
+        items.push({text: project, url: `/p/${project}/issues/list`});
+      });
+    }
+
+    if (items.length) {
+      items.push({separator: true});
+    }
+
+    items.push({text: 'All projects', url: '/hosting/'});
+    items.forEach((item) => {
+      item.handler = () => this._projectChangedHandler(item.url);
+    });
+    return items;
+  }
+
+  /**
+   * @return {Array<MenuItem>} dropdown menu items to show in the project
+   *   settings menu.
+   */
+  get _projectSettingsItems() {
+    const {projectName, canAdministerProject} = this;
+    const items = [
+      {text: 'People', url: `/p/${projectName}/people/list`},
+      {text: 'Development Process', url: `/p/${projectName}/adminIntro`},
+      {text: 'History', url: `/p/${projectName}/updates/list`},
+    ];
+
+    if (canAdministerProject) {
+      items.push({separator: true});
+      items.push({text: 'Administer', url: `/p/${projectName}/admin`});
+    }
+    return items;
+  }
+
+  /**
+   * Records Google Analytics events for when users change projects using
+   * the selector.
+   * @param {string} url which project URL the user is navigating to.
+   */
+  _projectChangedHandler(url) {
+    // Just log it to GA and continue.
+    logEvent('mr-header', 'project-change', url);
+  }
+}
+
+customElements.define('mr-header', MrHeader);
diff --git a/static_src/elements/framework/mr-header/mr-header.test.js b/static_src/elements/framework/mr-header/mr-header.test.js
new file mode 100644
index 0000000..277347f
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-header.test.js
@@ -0,0 +1,191 @@
+// 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 {assert} from 'chai';
+import sinon from 'sinon';
+
+import {prpcClient} from 'prpc-client-instance.js';
+import {MrHeader} from './mr-header.js';
+
+
+window.CS_env = {
+  token: 'foo-token',
+};
+
+let element;
+
+describe('mr-header', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-header');
+    document.body.appendChild(element);
+
+    window.ga = sinon.stub();
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrHeader);
+  });
+
+  it('presentationConfig renders', async () => {
+    element.projectName = 'best-project';
+    element.projectThumbnailUrl = 'http://images.google.com/';
+    element.presentationConfig = {
+      projectSummary: 'The best project',
+    };
+
+    await element.updateComplete;
+
+    assert.equal(element.shadowRoot.querySelector('.project-logo').src,
+        'http://images.google.com/');
+
+    assert.endsWith(element.shadowRoot.querySelector('.new-issue-link').href,
+        '/p/best-project/issues/entry');
+
+    assert.equal(element.shadowRoot.querySelector('.project-selector').title,
+        'The best project');
+  });
+
+  describe('issueEntryUrl', () => {
+    let oldToken;
+
+    beforeEach(() => {
+      oldToken = prpcClient.token;
+      prpcClient.token = 'token1';
+
+      element.projectName = 'proj';
+
+      sinon.stub(element, '_origin').get(() => 'http://localhost');
+    });
+
+    afterEach(() => {
+      prpcClient.token = oldToken;
+    });
+
+    it('updates on project change', async () => {
+      await element.updateComplete;
+
+      assert.endsWith(element.shadowRoot.querySelector('.new-issue-link').href,
+          '/p/proj/issues/entry');
+
+      element.projectName = 'the-best-project';
+
+      await element.updateComplete;
+
+      assert.endsWith(element.shadowRoot.querySelector('.new-issue-link').href,
+          '/p/the-best-project/issues/entry');
+    });
+
+    it('generates wizard URL when customIssueEntryUrl defined', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userProjects = {ownerOf: ['not-proj']};
+      element.userDisplayName = 'test@example.com';
+      assert.equal(element.issueEntryUrl,
+          'https://issue.wizard?token=token1&role=&' +
+          'continue=http://localhost/p/proj/issues/entry.do');
+    });
+
+    it('uses default issue filing URL when user is not logged in', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userDisplayName = '';
+      assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+    });
+
+    it('uses default issue filing URL when user is project owner', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userProjects = {ownerOf: ['proj']};
+      assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+    });
+
+    it('uses default issue filing URL when user is project member', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userProjects = {memberOf: ['proj']};
+      assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+    });
+
+    it('uses default issue filing URL when user is project contributor', () => {
+      element.presentationConfig = {customIssueEntryUrl: 'https://issue.wizard'};
+      element.userProjects = {contributorTo: ['proj']};
+      assert.equal(element.issueEntryUrl, '/p/proj/issues/entry');
+    });
+  });
+
+
+  it('canAdministerProject is false when user is not logged in', () => {
+    element.userDisplayName = '';
+
+    assert.isFalse(element.canAdministerProject);
+  });
+
+  it('canAdministerProject is true when user is site admin', () => {
+    element.userDisplayName = 'test@example.com';
+    element.isSiteAdmin = true;
+
+    assert.isTrue(element.canAdministerProject);
+
+    element.isSiteAdmin = false;
+
+    assert.isFalse(element.canAdministerProject);
+  });
+
+  it('canAdministerProject is true when user is owner', () => {
+    element.userDisplayName = 'test@example.com';
+    element.isSiteAdmin = false;
+
+    element.projectName = 'chromium';
+    element.userProjects = {ownerOf: ['chromium']};
+
+    assert.isTrue(element.canAdministerProject);
+
+    element.projectName = 'v8';
+
+    assert.isFalse(element.canAdministerProject);
+
+    element.userProjects = {memberOf: ['v8']};
+
+    assert.isFalse(element.canAdministerProject);
+  });
+
+  it('_projectDropdownItems tells user to sign in if not logged in', () => {
+    element.userDisplayName = '';
+    element.loginUrl = 'http://login';
+
+    const items = element._projectDropdownItems;
+
+    // My Projects
+    assert.deepEqual(items[0], {
+      text: 'Sign in to see your projects',
+      url: 'http://login',
+    });
+  });
+
+  it('_projectDropdownItems computes projects for user', () => {
+    element.userProjects = {
+      ownerOf: ['chromium'],
+      memberOf: ['v8'],
+      contributorTo: ['skia'],
+      starredProjects: ['gerrit'],
+    };
+    element.userDisplayName = 'test@example.com';
+
+    const items = element._projectDropdownItems;
+
+    // TODO(http://crbug.com/monorail/6236): Replace these checks with
+    // deepInclude once we upgrade Chai.
+    // My Projects
+    assert.equal(items[1].text, 'chromium');
+    assert.equal(items[1].url, '/p/chromium/issues/list');
+    assert.equal(items[2].text, 'skia');
+    assert.equal(items[2].url, '/p/skia/issues/list');
+    assert.equal(items[3].text, 'v8');
+    assert.equal(items[3].url, '/p/v8/issues/list');
+
+    // Starred Projects
+    assert.equal(items[5].text, 'gerrit');
+    assert.equal(items[5].url, '/p/gerrit/issues/list');
+  });
+});
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);
diff --git a/static_src/elements/framework/mr-header/mr-search-bar.test.js b/static_src/elements/framework/mr-header/mr-search-bar.test.js
new file mode 100644
index 0000000..c758a41
--- /dev/null
+++ b/static_src/elements/framework/mr-header/mr-search-bar.test.js
@@ -0,0 +1,244 @@
+// 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 {assert} from 'chai';
+import sinon from 'sinon';
+
+import {MrSearchBar} from './mr-search-bar.js';
+import {prpcClient} from 'prpc-client-instance.js';
+import {issueRefToUrl} from 'shared/convertersV0.js';
+import {clientLoggerFake} from 'shared/test/fakes.js';
+
+
+window.CS_env = {
+  token: 'foo-token',
+};
+
+let element;
+
+describe('mr-search-bar', () => {
+  beforeEach(() => {
+    element = document.createElement('mr-search-bar');
+    document.body.appendChild(element);
+  });
+
+  afterEach(() => {
+    document.body.removeChild(element);
+  });
+
+  it('initializes', () => {
+    assert.instanceOf(element, MrSearchBar);
+  });
+
+  it('render user saved queries', async () => {
+    element.userDisplayName = 'test@user.com';
+    element.userSavedQueries = [
+      {name: 'test query', queryId: 101},
+      {name: 'hello world', queryId: 202},
+    ];
+
+    await element.updateComplete;
+
+    const queryOptions = element.shadowRoot.querySelectorAll(
+        '.user-query');
+
+    assert.equal(queryOptions.length, 2);
+
+    assert.equal(queryOptions[0].value, '101');
+    assert.equal(queryOptions[0].textContent, 'test query');
+
+    assert.equal(queryOptions[1].value, '202');
+    assert.equal(queryOptions[1].textContent, 'hello world');
+  });
+
+  it('render project saved queries', async () => {
+    element.userDisplayName = 'test@user.com';
+    element.projectSavedQueries = [
+      {name: 'test query', queryId: 101},
+      {name: 'hello world', queryId: 202},
+    ];
+
+    await element.updateComplete;
+
+    const queryOptions = element.shadowRoot.querySelectorAll(
+        '.project-query');
+
+    assert.equal(queryOptions.length, 2);
+
+    assert.equal(queryOptions[0].value, '101');
+    assert.equal(queryOptions[0].textContent, 'test query');
+
+    assert.equal(queryOptions[1].value, '202');
+    assert.equal(queryOptions[1].textContent, 'hello world');
+  });
+
+  it('search input resets form value when initialQuery changes', async () => {
+    element.initialQuery = 'first query';
+    await element.updateComplete;
+
+    const queryInput = element.shadowRoot.querySelector('#searchq');
+
+    assert.equal(queryInput.value, 'first query');
+
+    // Simulate a user typing something into the search form.
+    queryInput.value = 'blah';
+
+    element.initialQuery = 'second query';
+    await element.updateComplete;
+
+    // 'blah' disappears because the new initialQuery causes the form to
+    // reset.
+    assert.equal(queryInput.value, 'second query');
+  });
+
+  it('unrelated property changes do not reset query form', async () => {
+    element.initialQuery = 'first query';
+    await element.updateComplete;
+
+    const queryInput = element.shadowRoot.querySelector('#searchq');
+
+    assert.equal(queryInput.value, 'first query');
+
+    // Simulate a user typing something into the search form.
+    queryInput.value = 'blah';
+
+    element.initialCan = '5';
+    await element.updateComplete;
+
+    assert.equal(queryInput.value, 'blah');
+  });
+
+  it('spell check is off for search bar', async () => {
+    await element.updateComplete;
+    const searchElement = element.shadowRoot.querySelector('#searchq');
+    assert.equal(searchElement.getAttribute('spellcheck'), 'false');
+  });
+
+  describe('search form submit', () => {
+    let prpcClientStub;
+    beforeEach(() => {
+      element.clientLogger = clientLoggerFake();
+
+      element._page = sinon.stub();
+      sinon.stub(window, 'open');
+
+      element.projectName = 'chromium';
+      prpcClientStub = sinon.stub(prpcClient, 'call');
+    });
+
+    afterEach(() => {
+      window.open.restore();
+      prpcClient.call.restore();
+    });
+
+    it('prevents default', async () => {
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      // Note: HTMLFormElement's submit function does not run submit handlers
+      // but clicking a submit buttons programmatically works.
+      const event = new Event('submit');
+      sinon.stub(event, 'preventDefault');
+      form.dispatchEvent(event);
+
+      sinon.assert.calledOnce(event.preventDefault);
+    });
+
+    it('uses initial values when no form changes', async () => {
+      element.initialQuery = 'test query';
+      element.initialCan = '3';
+
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.dispatchEvent(new Event('submit'));
+
+      sinon.assert.calledOnce(element._page);
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?q=test%20query&can=3');
+    });
+
+    it('adds form values to url', async () => {
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.q.value = 'test';
+      form.can.value = '1';
+
+      form.dispatchEvent(new Event('submit'));
+
+      sinon.assert.calledOnce(element._page);
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?q=test&can=1');
+    });
+
+    it('trims query', async () => {
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.q.value = '  abc  ';
+      form.can.value = '1';
+
+      form.dispatchEvent(new Event('submit'));
+
+      sinon.assert.calledOnce(element._page);
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?q=abc&can=1');
+    });
+
+    it('jumps to issue for digit-only query', async () => {
+      prpcClientStub.returns(Promise.resolve({issue: 'hello world'}));
+
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.q.value = '123';
+      form.can.value = '1';
+
+      form.dispatchEvent(new Event('submit'));
+
+      await element._navigateToNext;
+
+      const expected = issueRefToUrl('hello world', {q: '123', can: '1'});
+      sinon.assert.calledWith(element._page, expected);
+    });
+
+    it('only keeps kept query params', async () => {
+      element.queryParams = {fakeParam: 'test', x: 'Status'};
+      element.keptParams = ['x'];
+
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.dispatchEvent(new Event('submit'));
+
+      sinon.assert.calledOnce(element._page);
+      sinon.assert.calledWith(element._page,
+          '/p/chromium/issues/list?x=Status&q=&can=2');
+    });
+
+    it('on shift+enter opens search in new tab', async () => {
+      await element.updateComplete;
+
+      const form = element.shadowRoot.querySelector('form');
+
+      form.q.value = 'test';
+      form.can.value = '1';
+
+      // Dispatch event from an input in the form.
+      form.q.dispatchEvent(new KeyboardEvent('keypress',
+          {key: 'Enter', shiftKey: true, bubbles: true}));
+
+      sinon.assert.calledOnce(window.open);
+      sinon.assert.calledWith(window.open,
+          '/p/chromium/issues/list?q=test&can=1', '_blank', 'noopener');
+    });
+  });
+});