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:
parent
93a4c8e42d
commit
a979ab7281
@ -15,8 +15,8 @@ limitations under the License.
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<link rel="import" href="../../../bower_components/polymer/polymer.html">
|
<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="../../../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">
|
<link rel="import" href="../../shared/gr-button/gr-button.html">
|
||||||
|
|
||||||
<dom-module id="gr-search-bar">
|
<dom-module id="gr-search-bar">
|
||||||
@ -28,13 +28,14 @@ limitations under the License.
|
|||||||
form {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
input {
|
gr-autocomplete {
|
||||||
|
background-color: white;
|
||||||
border: 1px solid #d1d2d3;
|
border: 1px solid #d1d2d3;
|
||||||
border-radius: 2px 0 0 2px;
|
border-radius: 2px 0 0 2px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
padding: 0 .25em;
|
padding: 0 .25em 0 .25em;
|
||||||
}
|
}
|
||||||
gr-button {
|
gr-button {
|
||||||
background-color: #f1f2f3;
|
background-color: #f1f2f3;
|
||||||
@ -43,7 +44,14 @@ limitations under the License.
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<form>
|
<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>
|
<gr-button id="searchButton">Search</gr-button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
|
@ -14,6 +14,70 @@
|
|||||||
(function() {
|
(function() {
|
||||||
'use strict';
|
'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({
|
Polymer({
|
||||||
is: 'gr-search-bar',
|
is: 'gr-search-bar',
|
||||||
|
|
||||||
@ -22,7 +86,6 @@
|
|||||||
],
|
],
|
||||||
|
|
||||||
listeners: {
|
listeners: {
|
||||||
'searchInput.keydown': '_inputKeyDownHandler',
|
|
||||||
'searchButton.tap': '_preventDefaultAndNavigateToInputVal',
|
'searchButton.tap': '_preventDefaultAndNavigateToInputVal',
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -37,7 +100,12 @@
|
|||||||
type: Object,
|
type: Object,
|
||||||
value: function() { return document.body; },
|
value: function() { return document.body; },
|
||||||
},
|
},
|
||||||
|
query: {
|
||||||
|
type: Function,
|
||||||
|
value: function() {
|
||||||
|
return this._getSearchSuggestions.bind(this);
|
||||||
|
},
|
||||||
|
},
|
||||||
_inputVal: String,
|
_inputVal: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -45,10 +113,8 @@
|
|||||||
this._inputVal = value;
|
this._inputVal = value;
|
||||||
},
|
},
|
||||||
|
|
||||||
_inputKeyDownHandler: function(e) {
|
_handleInputCommit: function(e) {
|
||||||
if (e.keyCode == 13) { // Enter key
|
|
||||||
this._preventDefaultAndNavigateToInputVal(e);
|
this._preventDefaultAndNavigateToInputVal(e);
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
_preventDefaultAndNavigateToInputVal: function(e) {
|
_preventDefaultAndNavigateToInputVal: function(e) {
|
||||||
@ -58,6 +124,36 @@
|
|||||||
page.show('/q/' + encodeURIComponent(encodeURIComponent(this._inputVal)));
|
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) {
|
_handleKey: function(e) {
|
||||||
if (this.shouldSupressKeyboardShortcut(e)) { return; }
|
if (this.shouldSupressKeyboardShortcut(e)) { return; }
|
||||||
switch (e.keyCode) {
|
switch (e.keyCode) {
|
||||||
|
@ -68,15 +68,33 @@ limitations under the License.
|
|||||||
assert.notEqual(getActiveElement(), element.$.searchButton);
|
assert.notEqual(getActiveElement(), element.$.searchButton);
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
MockInteractions.pressAndReleaseKeyOn(element.$.searchInput, 13);
|
MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('search query should be double-escaped', function() {
|
test('search query should be double-escaped', function() {
|
||||||
var showStub = sinon.stub(page, 'show');
|
var showStub = sinon.stub(page, 'show');
|
||||||
element._inputVal = 'fate/stay';
|
element.$.searchInput.text = 'fate/stay';
|
||||||
MockInteractions.pressAndReleaseKeyOn(element.$.searchInput, 13);
|
MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13);
|
||||||
assert.equal(showStub.lastCall.args[0], '/q/fate%252Fstay');
|
assert.equal(showStub.lastCall.args[0], '/q/fate%252Fstay');
|
||||||
showStub.restore();
|
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>
|
</script>
|
||||||
|
@ -22,6 +22,7 @@ limitations under the License.
|
|||||||
<style>
|
<style>
|
||||||
input {
|
input {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
input.borderless,
|
input.borderless,
|
||||||
|
@ -65,6 +65,7 @@
|
|||||||
text: {
|
text: {
|
||||||
type: String,
|
type: String,
|
||||||
observer: '_updateSuggestions',
|
observer: '_updateSuggestions',
|
||||||
|
notify: true,
|
||||||
},
|
},
|
||||||
|
|
||||||
placeholder: String,
|
placeholder: String,
|
||||||
@ -76,6 +77,15 @@
|
|||||||
|
|
||||||
value: Object,
|
value: Object,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi mode appends autocompleted entries to the value.
|
||||||
|
* If false, autocompleted entries replace value.
|
||||||
|
*/
|
||||||
|
multi: {
|
||||||
|
type: Boolean,
|
||||||
|
value: false,
|
||||||
|
},
|
||||||
|
|
||||||
_suggestions: {
|
_suggestions: {
|
||||||
type: Array,
|
type: Array,
|
||||||
value: function() { return []; },
|
value: function() { return []; },
|
||||||
@ -87,6 +97,7 @@
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
value: false,
|
value: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
attached: function() {
|
attached: function() {
|
||||||
@ -121,7 +132,6 @@
|
|||||||
|
|
||||||
_updateSuggestions: function() {
|
_updateSuggestions: function() {
|
||||||
if (this._disableSuggestions) { return; }
|
if (this._disableSuggestions) { return; }
|
||||||
|
|
||||||
if (this.text.length < this.threshold) {
|
if (this.text.length < this.threshold) {
|
||||||
this._suggestions = [];
|
this._suggestions = [];
|
||||||
this.value = null;
|
this.value = null;
|
||||||
@ -169,6 +179,7 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this._cancel();
|
this._cancel();
|
||||||
break;
|
break;
|
||||||
|
case 9: // Tab
|
||||||
case 13: // Enter
|
case 13: // Enter
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this._commit();
|
this._commit();
|
||||||
@ -184,7 +195,14 @@
|
|||||||
|
|
||||||
_updateValue: function(suggestions, index) {
|
_updateValue: function(suggestions, index) {
|
||||||
if (!suggestions.length || index === -1) { return; }
|
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) {
|
_handleBodyClick: function(e) {
|
||||||
@ -203,15 +221,25 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
_commit: function() {
|
_commit: function() {
|
||||||
|
// Allow values that are not in suggestion list iff suggestions are empty.
|
||||||
|
if (this._suggestions.length > 0) {
|
||||||
this._updateValue(this._suggestions, this._index);
|
this._updateValue(this._suggestions, this._index);
|
||||||
|
} else {
|
||||||
|
this.value = this.text;
|
||||||
|
}
|
||||||
|
|
||||||
var value = this.value;
|
var value = this.value;
|
||||||
|
|
||||||
|
// Value and text are mirrors of each other in multi mode.
|
||||||
|
if (this.multi) {
|
||||||
|
this.setText(this.value);
|
||||||
|
} else {
|
||||||
if (!this.clearOnCommit && this._suggestions[this._index]) {
|
if (!this.clearOnCommit && this._suggestions[this._index]) {
|
||||||
this.setText(this._suggestions[this._index].name);
|
this.setText(this._suggestions[this._index].name);
|
||||||
} else {
|
} else {
|
||||||
this.clear();
|
this.clear();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.fire('commit', {value: value});
|
this.fire('commit', {value: value});
|
||||||
},
|
},
|
||||||
|
@ -214,5 +214,25 @@ limitations under the License.
|
|||||||
assert.equal(element._computeClass(false), '');
|
assert.equal(element._computeClass(false), '');
|
||||||
assert.equal(element._computeClass(true), 'borderless');
|
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>
|
</script>
|
||||||
|
Loading…
Reference in New Issue
Block a user