| // 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); |