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');
+ });
+ });
+});