blob: 5156c01449af20a8be03490115360649547641d8 [file] [log] [blame]
// Copyright 2019 The Chromium Authors
// 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);