/** * @license * Copyright (C) 2016 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ (function() { 'use strict'; // Possible static search options for auto complete, without negations. const SEARCH_OPERATORS = [ 'added:', 'age:', 'age:1week', // Give an example age 'assignee:', 'author:', 'branch:', 'bug:', 'cc:', 'cc:self', 'change:', 'comment:', 'commentby:', 'commit:', 'committer:', 'conflicts:', 'deleted:', 'delta:', 'dir:', 'directory:', 'ext:', 'extension:', 'file:', 'footer:', 'from:', 'has:', 'has:draft', 'has:edit', 'has:star', 'has:stars', 'has:unresolved', 'hashtag:', 'intopic:', 'is:', 'is:abandoned', 'is:assigned', 'is:closed', 'is:ignored', 'is:mergeable', 'is:merged', 'is:open', 'is:owner', 'is:private', 'is:reviewed', 'is:reviewer', 'is:starred', 'is:submittable', 'is:watched', 'is:wip', 'label:', 'message:', 'onlyexts:', 'onlyextensions:', 'owner:', 'ownerin:', 'parentproject:', 'project:', 'projects:', 'query:', 'ref:', 'reviewedby:', 'reviewer:', 'reviewer:self', 'reviewerin:', 'size:', 'star:', 'status:', 'status:abandoned', 'status:closed', 'status:merged', 'status:open', 'status:reviewed', 'topic:', 'tr:', ]; // All of the ops, with corresponding negations. const SEARCH_OPERATORS_WITH_NEGATIONS = SEARCH_OPERATORS.concat(SEARCH_OPERATORS.map(op => `-${op}`)); const MAX_AUTOCOMPLETE_RESULTS = 10; const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g; Polymer({ is: 'gr-search-bar', /** * Fired when a search is committed * * @event handle-search */ behaviors: [ Gerrit.KeyboardShortcutBehavior, Gerrit.URLEncodingBehavior, ], properties: { value: { type: String, value: '', notify: true, observer: '_valueChanged', }, keyEventTarget: { type: Object, value() { return document.body; }, }, query: { type: Function, value() { return this._getSearchSuggestions.bind(this); }, }, projectSuggestions: { type: Function, value() { return () => Promise.resolve([]); }, }, groupSuggestions: { type: Function, value() { return () => Promise.resolve([]); }, }, accountSuggestions: { type: Function, value() { return () => Promise.resolve([]); }, }, _inputVal: String, _threshold: { type: Number, value: 1, }, }, keyboardShortcuts() { return { [this.Shortcut.SEARCH]: '_handleSearch', }; }, _valueChanged(value) { this._inputVal = value; }, _handleInputCommit(e) { this._preventDefaultAndNavigateToInputVal(e); }, /** * This function is called in a few different cases: * - e.target is the search button * - e.target is the gr-autocomplete widget (#searchInput) * - e.target is the input element wrapped within #searchInput * * @param {!Event} e */ _preventDefaultAndNavigateToInputVal(e) { e.preventDefault(); const target = Polymer.dom(e).rootTarget; // If the target is the #searchInput or has a sub-input component, that // is what holds the focus as opposed to the target from the DOM event. if (target.$.input) { target.$.input.blur(); } else { target.blur(); } const trimmedInput = this._inputVal && this._inputVal.trim(); if (trimmedInput) { const predefinedOpOnlyQuery = SEARCH_OPERATORS_WITH_NEGATIONS.some( op => { return op.endsWith(':') && op === trimmedInput; } ); if (predefinedOpOnlyQuery) { return; } this.dispatchEvent(new CustomEvent('handle-search', { detail: {inputVal: this._inputVal}, })); } }, /** * Determine what array of possible suggestions should be provided * to _getSearchSuggestions. * * @param {string} input - The full search term, in lowercase. * @return {!Promise} This returns a promise that resolves to an array of * suggestion objects. */ _fetchSuggestions(input) { // Split the input on colon to get a two part predicate/expression. const splitInput = input.split(':'); const predicate = splitInput[0]; const expression = splitInput[1] || ''; // Switch on the predicate to determine what to autocomplete. switch (predicate) { case 'ownerin': case 'reviewerin': // Fetch groups. return this.groupSuggestions(predicate, expression); case 'parentproject': case 'project': // Fetch projects. return this.projectSuggestions(predicate, expression); case 'author': case 'cc': case 'commentby': case 'committer': case 'from': case 'owner': case 'reviewedby': case 'reviewer': // Fetch accounts. return this.accountSuggestions(predicate, expression); default: return Promise.resolve(SEARCH_OPERATORS_WITH_NEGATIONS .filter(operator => operator.includes(input)) .map(operator => ({text: operator}))); } }, /** * Get the sorted, pruned list of suggestions for the current search query. * * @param {string} input - The complete search query. * @return {!Promise} This returns a promise that resolves to an array of * suggestions. */ _getSearchSuggestions(input) { // Allow spaces within quoted terms. const tokens = input.match(TOKENIZE_REGEX); const trimmedInput = tokens[tokens.length - 1].toLowerCase(); return this._fetchSuggestions(trimmedInput) .then(suggestions => { if (!suggestions || !suggestions.length) { return []; } return suggestions // Prioritize results that start with the input. .sort((a, b) => { const aContains = a.text.toLowerCase().indexOf(trimmedInput); const bContains = b.text.toLowerCase().indexOf(trimmedInput); if (aContains === bContains) { return a.text.localeCompare(b.text); } if (aContains === -1) { return 1; } if (bContains === -1) { return -1; } return aContains - bContains; }) // Return only the first {MAX_AUTOCOMPLETE_RESULTS} results. .slice(0, MAX_AUTOCOMPLETE_RESULTS - 1) // Map to an object to play nice with gr-autocomplete. .map(({text, label}) => { return { name: text, value: text, label, }; }); }); }, _handleSearch(e) { const keyboardEvent = this.getKeyboardEvent(e); if (this.shouldSuppressKeyboardShortcut(e) || (this.modifierPressed(e) && !keyboardEvent.shiftKey)) { return; } e.preventDefault(); this.$.searchInput.focus(); this.$.searchInput.selectAll(); }, }); })();