Implement simple autocomplete for the search bar

This change modifies the existing search bar component by replacing
the existing iron-input implementation with a gr-autocomplete
component. Rudimentary autocompletion for the search bar now exists
-- specific autocomplete features, I.E. backend querying for
possible usernames when 'author:' is entered, are yet to be
implemented.

This task required modification of the gr-autocomplete element to
support multiple search terms -- this was accomplished by the
addition of a boolean prop, 'multi', that is false by default.
When multi is true, the component will only look to autocomplete
the substring from the index of the last space til the end.

Possible enhancements to this change include:
    - addition of a 'delimiter' prop, as opposed to the hardcoded
    space delimiter
    - UI modifications to allow deleting of specific search terms,
    like a multi-select tag-styled input.

Bug: Issue 4276
Change-Id: Ic04981af06ba34dd1d58b023fcf444e4168d6b18
This commit is contained in:
Kasper Nilsson 2016-08-11 15:57:01 -07:00
parent 93a4c8e42d
commit a979ab7281
6 changed files with 190 additions and 19 deletions

View File

@ -15,8 +15,8 @@ limitations under the License.
-->
<link rel="import" href="../../../bower_components/polymer/polymer.html">
<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior.html">
<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
<link rel="import" href="../../shared/gr-button/gr-button.html">
<dom-module id="gr-search-bar">
@ -28,13 +28,14 @@ limitations under the License.
form {
display: flex;
}
input {
gr-autocomplete {
background-color: white;
border: 1px solid #d1d2d3;
border-radius: 2px 0 0 2px;
flex: 1;
font: inherit;
outline: none;
padding: 0 .25em;
padding: 0 .25em 0 .25em;
}
gr-button {
background-color: #f1f2f3;
@ -43,7 +44,14 @@ limitations under the License.
}
</style>
<form>
<input is="iron-input" id="searchInput" bind-value="{{_inputVal}}">
<gr-autocomplete
id="searchInput"
text="{{_inputVal}}"
query="[[query]]"
on-commit="_handleInputCommit"
allowNonSuggestedValues
multi
borderless></gr-autocomplete>
<gr-button id="searchButton">Search</gr-button>
</form>
</template>

View File

@ -14,6 +14,70 @@
(function() {
'use strict';
// Possible static search options for auto complete.
var SEARCH_OPERATORS = [
'added',
'age',
'age:1week', // Give an example age
'author',
'branch',
'bug',
'change',
'comment',
'commentby',
'commit',
'committer',
'conflicts',
'deleted',
'delta',
'file',
'from',
'has',
'has:draft',
'has:edit',
'has:star',
'has:stars',
'intopic',
'is',
'is:abandoned',
'is:closed',
'is:draft',
'is:mergeable',
'is:merged',
'is:open',
'is:owner',
'is:pending',
'is:reviewed',
'is:reviewer',
'is:starred',
'is:watched',
'label',
'message',
'owner',
'ownerin',
'parentproject',
'project',
'projects',
'query',
'ref',
'reviewedby',
'reviewer',
'reviewer:self',
'reviewerin',
'size',
'star',
'status',
'status:abandoned',
'status:closed',
'status:draft',
'status:merged',
'status:open',
'status:pending',
'status:reviewed',
'topic',
'tr',
];
Polymer({
is: 'gr-search-bar',
@ -22,7 +86,6 @@
],
listeners: {
'searchInput.keydown': '_inputKeyDownHandler',
'searchButton.tap': '_preventDefaultAndNavigateToInputVal',
},
@ -37,7 +100,12 @@
type: Object,
value: function() { return document.body; },
},
query: {
type: Function,
value: function() {
return this._getSearchSuggestions.bind(this);
},
},
_inputVal: String,
},
@ -45,10 +113,8 @@
this._inputVal = value;
},
_inputKeyDownHandler: function(e) {
if (e.keyCode == 13) { // Enter key
this._preventDefaultAndNavigateToInputVal(e);
}
_handleInputCommit: function(e) {
this._preventDefaultAndNavigateToInputVal(e);
},
_preventDefaultAndNavigateToInputVal: function(e) {
@ -58,6 +124,36 @@
page.show('/q/' + encodeURIComponent(encodeURIComponent(this._inputVal)));
},
// TODO(kaspern): Flesh this out better.
_makeSuggestion: function(str) {
return {
name: str,
value: str,
};
},
// TODO(kaspern): Expand support for more complicated autocomplete features.
_getSearchSuggestions: function(input) {
return Promise.resolve(SEARCH_OPERATORS).then(function(operators) {
if (!operators) { return []; }
var lowerCaseInput = input
.substring(input.lastIndexOf(' ') + 1)
.toLowerCase();
return operators
.filter(function(operator) {
// Disallow autocomplete values that exactly match the whole str.
var opContainsInput = operator.indexOf(lowerCaseInput) !== -1;
var inputContainsOp = lowerCaseInput.indexOf(operator) !== -1;
return opContainsInput && !inputContainsOp;
})
// Prioritize results that start with the input.
.sort(function(operator) {
return operator.indexOf(lowerCaseInput);
})
.map(this._makeSuggestion);
}.bind(this));
},
_handleKey: function(e) {
if (this.shouldSupressKeyboardShortcut(e)) { return; }
switch (e.keyCode) {

View File

@ -68,15 +68,33 @@ limitations under the License.
assert.notEqual(getActiveElement(), element.$.searchButton);
done();
});
MockInteractions.pressAndReleaseKeyOn(element.$.searchInput, 13);
MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
});
test('search query should be double-escaped', function() {
var showStub = sinon.stub(page, 'show');
element._inputVal = 'fate/stay';
MockInteractions.pressAndReleaseKeyOn(element.$.searchInput, 13);
element.$.searchInput.text = 'fate/stay';
MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
assert.equal(showStub.lastCall.args[0], '/q/fate%252Fstay');
showStub.restore();
});
test('_getSearchSuggestions returns proper set of suggestions',
function(done) {
element._getSearchSuggestions('is:o')
.then(function(suggestions) {
assert.equal(suggestions[0].name, 'is:open');
assert.equal(suggestions[0].value, 'is:open');
assert.equal(suggestions[1].name, 'is:owner');
assert.equal(suggestions[1].value, 'is:owner');
})
.then(function() {
element._getSearchSuggestions('asdasdasdasd')
.then(function(suggestions) {
assert.equal(suggestions.length, 0);
done();
});
});
});
});
</script>

View File

@ -22,6 +22,7 @@ limitations under the License.
<style>
input {
font-size: 1em;
height: 100%;
width: 100%;
}
input.borderless,

View File

@ -65,6 +65,7 @@
text: {
type: String,
observer: '_updateSuggestions',
notify: true,
},
placeholder: String,
@ -76,6 +77,15 @@
value: Object,
/**
* Multi mode appends autocompleted entries to the value.
* If false, autocompleted entries replace value.
*/
multi: {
type: Boolean,
value: false,
},
_suggestions: {
type: Array,
value: function() { return []; },
@ -87,6 +97,7 @@
type: Boolean,
value: false,
},
},
attached: function() {
@ -121,7 +132,6 @@
_updateSuggestions: function() {
if (this._disableSuggestions) { return; }
if (this.text.length < this.threshold) {
this._suggestions = [];
this.value = null;
@ -169,6 +179,7 @@
e.preventDefault();
this._cancel();
break;
case 9: // Tab
case 13: // Enter
e.preventDefault();
this._commit();
@ -184,7 +195,14 @@
_updateValue: function(suggestions, index) {
if (!suggestions.length || index === -1) { return; }
this.value = suggestions[index].value;
var completed = suggestions[index].value;
if (this.multi) {
// Append the completed text to the end of the string.
var shortStr = this.text.substring(0, this.text.lastIndexOf(' ') + 1);
this.value = shortStr + completed;
} else {
this.value = completed;
}
},
_handleBodyClick: function(e) {
@ -203,14 +221,24 @@
},
_commit: function() {
this._updateValue(this._suggestions, this._index);
// Allow values that are not in suggestion list iff suggestions are empty.
if (this._suggestions.length > 0) {
this._updateValue(this._suggestions, this._index);
} else {
this.value = this.text;
}
var value = this.value;
if (!this.clearOnCommit && this._suggestions[this._index]) {
this.setText(this._suggestions[this._index].name);
// Value and text are mirrors of each other in multi mode.
if (this.multi) {
this.setText(this.value);
} else {
this.clear();
if (!this.clearOnCommit && this._suggestions[this._index]) {
this.setText(this._suggestions[this._index].name);
} else {
this.clear();
}
}
this.fire('commit', {value: value});

View File

@ -214,5 +214,25 @@ limitations under the License.
assert.equal(element._computeClass(false), '');
assert.equal(element._computeClass(true), 'borderless');
});
test('multi completes only the last part of the query', function(done) {
var promise;
var queryStub = sinon.stub()
.returns(promise = Promise.resolve([{name: 'suggestion', value: 0}]));
element.query = queryStub;
element.text = 'blah blah';
element.multi = true;
promise.then(function() {
var commitHandler = sinon.spy();
element.addEventListener('commit', commitHandler);
MockInteractions.pressAndReleaseKeyOn(element.$.input, 13); // Enter
assert.isTrue(commitHandler.called);
assert.equal(element.text, 'blah 0');
done();
});
});
});
</script>