blob: 536dfcf24c5bbaf3c7b5a4f2deeddf0185c8009b [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {LitElement, html, css} from 'lit-element';
6import page from 'page';
7import qs from 'qs';
8
9import '../mr-dropdown/mr-dropdown.js';
10import {prpcClient} from 'prpc-client-instance.js';
11import ClientLogger from 'monitoring/client-logger';
12import {issueRefToUrl} from 'shared/convertersV0.js';
13
14// Search field input regex testing for all digits
15// indicating that the user wants to jump to the specified issue.
16const JUMP_RE = /^\d+$/;
17
18/**
19 * `<mr-search-bar>`
20 *
21 * The searchbar for Monorail.
22 *
23 */
24export class MrSearchBar extends LitElement {
25 /** @override */
26 static get styles() {
27 return css`
28 :host {
29 --mr-search-bar-background: var(--chops-white);
30 --mr-search-bar-border-radius: 4px;
31 --mr-search-bar-border: var(--chops-normal-border);
32 --mr-search-bar-chip-color: var(--chops-gray-200);
33 height: 30px;
34 font-size: var(--chops-large-font-size);
35 }
36 input#searchq {
37 display: flex;
38 align-items: center;
39 justify-content: flex-start;
40 flex-grow: 2;
41 min-width: 100px;
42 border: none;
43 border-top: var(--mr-search-bar-border);
44 border-bottom: var(--mr-search-bar-border);
45 background: var(--mr-search-bar-background);
46 height: 100%;
47 box-sizing: border-box;
48 padding: 0 2px;
49 font-size: inherit;
50 }
51 mr-dropdown {
52 text-align: right;
53 display: flex;
54 text-overflow: ellipsis;
55 box-sizing: border-box;
56 background: var(--mr-search-bar-background);
57 border: var(--mr-search-bar-border);
58 border-left: 0;
59 border-radius: 0 var(--mr-search-bar-border-radius)
60 var(--mr-search-bar-border-radius) 0;
61 height: 100%;
62 align-items: center;
63 justify-content: center;
64 text-decoration: none;
65 }
66 button {
67 font-size: inherit;
68 order: -1;
69 background: var(--mr-search-bar-background);
70 cursor: pointer;
71 display: flex;
72 align-items: center;
73 justify-content: center;
74 height: 100%;
75 box-sizing: border-box;
76 border: var(--mr-search-bar-border);
77 border-left: none;
78 border-right: none;
79 padding: 0 8px;
80 }
81 form {
82 display: flex;
83 height: 100%;
84 width: 100%;
85 align-items: center;
86 justify-content: flex-start;
87 flex-direction: row;
88 }
89 i.material-icons {
90 font-size: var(--chops-icon-font-size);
91 color: var(--chops-primary-icon-color);
92 }
93 .select-container {
94 order: -2;
95 max-width: 150px;
96 min-width: 50px;
97 flex-shrink: 1;
98 height: 100%;
99 position: relative;
100 box-sizing: border-box;
101 border: var(--mr-search-bar-border);
102 border-radius: var(--mr-search-bar-border-radius) 0 0
103 var(--mr-search-bar-border-radius);
104 background: var(--mr-search-bar-chip-color);
105 }
106 .select-container i.material-icons {
107 display: flex;
108 align-items: center;
109 justify-content: center;
110 position: absolute;
111 right: 0;
112 top: 0;
113 height: 100%;
114 width: 20px;
115 z-index: 2;
116 padding: 0;
117 }
118 select {
119 color: var(--chops-primary-font-color);
120 display: flex;
121 align-items: center;
122 justify-content: flex-start;
123 -webkit-appearance: none;
124 -moz-appearance: none;
125 appearance: none;
126 text-overflow: ellipsis;
127 cursor: pointer;
128 width: 100%;
129 height: 100%;
130 background: none;
131 margin: 0;
132 padding: 0 20px 0 8px;
133 box-sizing: border-box;
134 border: 0;
135 z-index: 3;
136 font-size: inherit;
137 position: relative;
138 }
139 select::-ms-expand {
140 display: none;
141 }
142 select::after {
143 position: relative;
144 right: 0;
145 content: 'arrow_drop_down';
146 font-family: 'Material Icons';
147 }
148 `;
149 }
150
151 /** @override */
152 render() {
153 return html`
154 <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
155 <form
156 @submit=${this._submitSearch}
157 @keypress=${this._submitSearchWithKeypress}
158 >
159 ${this._renderSearchScopeSelector()}
160 <input
161 id="searchq"
162 type="text"
163 name="q"
164 placeholder="Search ${this.projectName} issues..."
165 .value=${this.initialQuery || ''}
166 autocomplete="off"
167 aria-label="Search box"
168 @focus=${this._searchEditStarted}
169 @blur=${this._searchEditFinished}
170 spellcheck="false"
171 />
172 <button type="submit">
173 <i class="material-icons">search</i>
174 </button>
175 <mr-dropdown
176 label="Search options"
177 .items=${this._searchMenuItems}
178 ></mr-dropdown>
179 </form>
180 `;
181 }
182
183 /**
184 * Render helper for the select menu that lets user select which search
185 * context/saved query they want to use.
186 * @return {TemplateResult}
187 */
188 _renderSearchScopeSelector() {
189 return html`
190 <div class="select-container">
191 <i class="material-icons" role="presentation">arrow_drop_down</i>
192 <select
193 id="can"
194 name="can"
195 @change=${this._redirectOnSelect}
196 aria-label="Search scope"
197 >
198 <optgroup label="Search within">
199 <option
200 value="1"
201 ?selected=${this.initialCan === '1'}
202 >All issues</option>
203 <option
204 value="2"
205 ?selected=${this.initialCan === '2'}
206 >Open issues</option>
207 <option
208 value="3"
209 ?selected=${this.initialCan === '3'}
210 >Open and owned by me</option>
211 <option
212 value="4"
213 ?selected=${this.initialCan === '4'}
214 >Open and reported by me</option>
215 <option
216 value="5"
217 ?selected=${this.initialCan === '5'}
218 >Open and starred by me</option>
219 <option
220 value="8"
221 ?selected=${this.initialCan === '8'}
222 >Open with comment by me</option>
223 <option
224 value="6"
225 ?selected=${this.initialCan === '6'}
226 >New issues</option>
227 <option
228 value="7"
229 ?selected=${this.initialCan === '7'}
230 >Issues to verify</option>
231 </optgroup>
232 <optgroup label="Project queries" ?hidden=${!this.userDisplayName}>
233 ${this._renderSavedQueryOptions(this.projectSavedQueries, 'project-query')}
234 <option data-href="/p/${this.projectName}/adminViews">
235 Manage project queries...
236 </option>
237 </optgroup>
238 <optgroup label="My saved queries" ?hidden=${!this.userDisplayName}>
239 ${this._renderSavedQueryOptions(this.userSavedQueries, 'user-query')}
240 <option data-href="/u/${this.userDisplayName}/queries">
241 Manage my saved queries...
242 </option>
243 </optgroup>
244 </select>
245 </div>
246 `;
247 }
248
249 /**
250 * Render helper for adding saved queries to the search scope select.
251 * @param {Array<SavedQuery>} queries Queries to render.
252 * @param {string} className CSS class to be applied to each option.
253 * @return {Array<TemplateResult>}
254 */
255 _renderSavedQueryOptions(queries, className) {
256 if (!queries) return;
257 return queries.map((query) => html`
258 <option
259 class=${className}
260 value=${query.queryId}
261 ?selected=${this.initialCan === query.queryId}
262 >${query.name}</option>
263 `);
264 }
265
266 /** @override */
267 static get properties() {
268 return {
269 projectName: {type: String},
270 userDisplayName: {type: String},
271 initialCan: {type: String},
272 initialQuery: {type: String},
273 projectSavedQueries: {type: Array},
274 userSavedQueries: {type: Array},
275 queryParams: {type: Object},
276 keptQueryParams: {type: Array},
277 };
278 }
279
280 /** @override */
281 constructor() {
282 super();
283 this.queryParams = {};
284 this.keptQueryParams = [
285 'sort',
286 'groupby',
287 'colspec',
288 'x',
289 'y',
290 'mode',
291 'cells',
292 'num',
293 ];
294 this.initialQuery = '';
295 this.initialCan = '2';
296 this.projectSavedQueries = [];
297 this.userSavedQueries = [];
298
299 this.clientLogger = new ClientLogger('issues');
300
301 this._page = page;
302 }
303
304 /** @override */
305 connectedCallback() {
306 super.connectedCallback();
307
308 // Global event listeners. Make sure to unbind these when the
309 // element disconnects.
310 this._boundFocus = this.focus.bind(this);
311 window.addEventListener('focus-search', this._boundFocus);
312 }
313
314 /** @override */
315 disconnectedCallback() {
316 super.disconnectedCallback();
317
318 window.removeEventListener('focus-search', this._boundFocus);
319 }
320
321 /** @override */
322 updated(changedProperties) {
323 if (this.userDisplayName && changedProperties.has('userDisplayName')) {
324 const userSavedQueriesPromise = prpcClient.call('monorail.Users',
325 'GetSavedQueries', {});
326 userSavedQueriesPromise.then((resp) => {
327 this.userSavedQueries = resp.savedQueries;
328 });
329 }
330 }
331
332 /**
333 * Sends an event to ClientLogger describing that the user started typing
334 * a search query.
335 */
336 _searchEditStarted() {
337 this.clientLogger.logStart('query-edit', 'user-time');
338 this.clientLogger.logStart('issue-search', 'user-time');
339 }
340
341 /**
342 * Sends an event to ClientLogger saying that the user finished typing a
343 * search.
344 */
345 _searchEditFinished() {
346 this.clientLogger.logEnd('query-edit');
347 }
348
349 /**
350 * On Shift+Enter, this handler opens the search in a new tab.
351 * @param {KeyboardEvent} e
352 */
353 _submitSearchWithKeypress(e) {
354 if (e.key === 'Enter' && (e.shiftKey)) {
355 const form = e.currentTarget;
356 this._runSearch(form, true);
357 }
358 // In all other cases, we want to let the submit handler do the work.
359 // ie: pressing 'Enter' on a form should natively open it in a new tab.
360 }
361
362 /**
363 * Update the URL on form submit.
364 * @param {Event} e
365 */
366 _submitSearch(e) {
367 e.preventDefault();
368
369 const form = e.target;
370 this._runSearch(form);
371 }
372
373 /**
374 * Updates the URL with the new search set in the query string.
375 * @param {HTMLFormElement} form the native form element to submit.
376 * @param {boolean=} newTab whether to open the search in a new tab.
377 */
378 _runSearch(form, newTab) {
379 this.clientLogger.logEnd('query-edit');
380 this.clientLogger.logPause('issue-search', 'user-time');
381 this.clientLogger.logStart('issue-search', 'computer-time');
382
383 const params = {};
384
385 this.keptQueryParams.forEach((param) => {
386 if (param in this.queryParams) {
387 params[param] = this.queryParams[param];
388 }
389 });
390
391 params.q = form.q.value.trim();
392 params.can = form.can.value;
393
394 this._navigateToNext(params, newTab);
395 }
396
397 /**
398 * Attempt to jump-to-issue, otherwise continue to list view
399 * @param {Object} params URL navigation parameters
400 * @param {boolean} newTab
401 */
402 async _navigateToNext(params, newTab = false) {
403 let resp;
404 if (JUMP_RE.test(params.q)) {
405 const message = {
406 issueRef: {
407 projectName: this.projectName,
408 localId: params.q,
409 },
410 };
411
412 try {
413 resp = await prpcClient.call(
414 'monorail.Issues', 'GetIssue', message,
415 );
416 } catch (error) {
417 // Fall through to navigateToList
418 }
419 }
420 if (resp && resp.issue) {
421 const link = issueRefToUrl(resp.issue, params);
422 this._page(link);
423 } else {
424 this._navigateToList(params, newTab);
425 }
426 }
427
428 /**
429 * Navigate to list view, currently splits on old and new view
430 * @param {Object} params URL navigation parameters
431 * @param {boolean} newTab
432 * @fires Event#refreshList
433 * @private
434 */
435 _navigateToList(params, newTab = false) {
436 const pathname = `/p/${this.projectName}/issues/list`;
437
438 const hasChanges = !window.location.pathname.startsWith(pathname) ||
439 this.queryParams.q !== params.q ||
440 this.queryParams.can !== params.can;
441
442 const url =`${pathname}?${qs.stringify(params)}`;
443
444 if (newTab) {
445 window.open(url, '_blank', 'noopener');
446 } else if (hasChanges) {
447 this._page(url);
448 } else {
449 // TODO(zhangtiff): Replace this event with Redux once all of Monorail
450 // uses Redux.
451 // This is needed because navigating to the exact same page does not
452 // cause a URL change to happen.
453 this.dispatchEvent(new Event('refreshList',
454 {'composed': true, 'bubbles': true}));
455 }
456 }
457
458 /**
459 * Wrap the native focus() function for the search form to allow parent
460 * elements to focus the search.
461 */
462 focus() {
463 const search = this.shadowRoot.querySelector('#searchq');
464 search.focus();
465 }
466
467 /**
468 * Populates the search dropdown.
469 * @return {Array<MenuItem>}
470 */
471 get _searchMenuItems() {
472 const projectName = this.projectName;
473 return [
474 {
475 text: 'Advanced search',
476 url: `/p/${projectName}/issues/advsearch`,
477 },
478 {
479 text: 'Search tips',
480 url: `/p/${projectName}/issues/searchtips`,
481 },
482 ];
483 }
484
485 /**
486 * The search dropdown includes links like "Manage my saved queries..."
487 * that automatically navigate a user to a new page when they select those
488 * options.
489 * @param {Event} evt
490 */
491 _redirectOnSelect(evt) {
492 const target = evt.target;
493 const option = target.options[target.selectedIndex];
494
495 if (option.dataset.href) {
496 this._page(option.dataset.href);
497 }
498 }
499}
500
501customElements.define('mr-search-bar', MrSearchBar);