[IMP] new autocomplete widget

Task #5009

This new autocomplete widget (the one used in the search bar) does not
do remote calls automatically, but on demand. In theory, it should lead
to a better user experience, not having the ui blocked every time long
remote calls are done.

It also has the benefits of bringing us one step closer to not
depending on jquery.ui. Bonus point: the code is quite short (< 200 loc
i believe)
This commit is contained in:
Géry Debongnie 2014-06-25 13:26:41 +02:00
parent 1eaa69d342
commit 0a4a7f3d20
5 changed files with 308 additions and 55 deletions

View File

@ -1588,6 +1588,38 @@
-webkit-border-radius: 2px;
border-radius: 2px;
}
.openerp .oe_searchview .oe-autocomplete {
display: none;
position: absolute;
width: 300px;
background-color: white;
border: 1px solid black;
z-index: 666;
margin-top: 5px;
cursor: default;
}
.openerp .oe_searchview .oe-autocomplete ul {
list-style-type: none;
padding-left: 0;
margin: 5px;
}
.openerp .oe_searchview .oe-autocomplete ul li {
padding-left: 20px;
text-shadow: 0 0 0 white;
}
.openerp .oe_searchview .oe-autocomplete ul li span:first-child {
margin-right: 5px;
}
.openerp .oe_searchview .oe-autocomplete ul li span.oe-expand {
cursor: pointer;
}
.openerp .oe_searchview .oe-autocomplete ul li.oe-indent {
margin-left: 20px;
}
.openerp .oe_searchview .oe-autocomplete ul li.oe-selection-focus {
background-color: #7c7bad;
color: white;
}
.openerp .oe_searchview_drawer_container {
overflow: auto;
}

View File

@ -1307,6 +1307,31 @@ $sheet-padding: 16px
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.4)
@include radius(2px)
.oe-autocomplete
display: none
position: absolute
width: 300px
background-color: white
border: 1px solid black
z-index: 666
margin-top: 5px
cursor: default
ul
list-style-type: none
padding-left: 0
margin: 5px
li
padding-left: 20px
text-shadow: 0 0 0 white
span:first-child
margin-right: 5px
span.oe-expand
cursor: pointer
li.oe-indent
margin-left: 20px
li.oe-selection-focus
background-color: #7c7bad
color: white
.oe_searchview_drawer_container
overflow: auto
.oe_searchview_drawer

View File

@ -350,7 +350,9 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea
e.preventDefault();
break;
case $.ui.keyCode.RIGHT:
this.focusFollowing(e.target);
if (!this.autocomplete.is_expandable()) {
this.focusFollowing(e.target);
}
e.preventDefault();
break;
}
@ -484,53 +486,18 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea
* Sets up search view's view-wide auto-completion widget
*/
setup_global_completion: function () {
var autocomplete = this.$el.autocomplete({
var self = this;
this.autocomplete = new instance.web.search.AutoComplete(this, {
source: this.proxy('complete_global_search'),
select: this.proxy('select_completion'),
focus: function (e) { e.preventDefault(); },
html: true,
autoFocus: true,
minLength: 1,
delay: 250,
}).data('autocomplete');
this.$el.on('input', function () {
this.$el.autocomplete('close');
}.bind(this));
// MonkeyPatch autocomplete instance
_.extend(autocomplete, {
_renderItem: function (ul, item) {
// item of completion list
var $item = $( "<li></li>" )
.data( "item.autocomplete", item )
.appendTo( ul );
if (item.facet !== undefined) {
// regular completion item
if (item.first) {
$item.css('borderTop', '1px solid #cccccc');
}
return $item.append(
(item.label)
? $('<a>').html(item.label)
: $('<a>').text(item.value));
}
return $item.text(item.label)
.css({
borderTop: '1px solid #cccccc',
margin: 0,
padding: 0,
zoom: 1,
'float': 'left',
clear: 'left',
width: '100%'
});
},
_value: function() {
delay: 0,
get_search_string: function () {
return self.$('div.oe_searchview_input').text();
},
width: this.$el.width(),
});
this.autocomplete.appendTo(this.$el);
},
/**
* Provide auto-completion result for req.term (an array to `resp`)
@ -579,14 +546,9 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea
childBlurred: function () {
var val = this.$el.val();
this.$el.val('');
var complete = this.$el.data('autocomplete');
if ((val && complete.term === undefined) || complete.previous) {
throw new Error("new jquery.ui version altering implementation" +
" details relied on");
}
delete complete.term;
this.$el.removeClass('oe_focused')
.trigger('blur');
this.autocomplete.close();
},
/**
* Call the renderFacets method with the correct arguments.
@ -1639,7 +1601,25 @@ instance.web.search.ManyToOneField = instance.web.search.CharField.extend({
this._super(view_section, field, parent);
this.model = new instance.web.Model(this.attrs.relation);
},
complete: function (needle) {
complete: function (value) {
if (_.isEmpty(value)) { return $.when(null); }
var label = _.str.sprintf(_.str.escapeHTML(
_t("Search %(field)s for: %(value)s")), {
field: '<em>' + _.escape(this.attrs.string) + '</em>',
value: '<strong>' + _.escape(value) + '</strong>'});
return $.when([{
label: label,
facet: {
category: this.attrs.string,
field: this,
values: [{label: value, value: value}]
},
expand: this.expand.bind(this),
}]);
},
expand: function (needle) {
var self = this;
// FIXME: "concurrent" searches (multiple requests, mis-ordered responses)
var context = instance.web.pyeval.eval(
@ -2311,6 +2291,205 @@ instance.web.search.custom_filters = new instance.web.Registry({
'id': 'instance.web.search.ExtendedSearchProposition.Id'
});
instance.web.search.AutoComplete = instance.web.Widget.extend({
template: "SearchView.autocomplete",
// Parameters for autocomplete constructor:
//
// parent: this is used to detect keyboard events
//
// options.source: function ({term:query}, callback). This function will be called to
// obtain the search results corresponding to the query string. It is assumed that
// options.source will call callback with the results.
// options.delay: delay in millisecond before calling source. Useful if you don't want
// to make too many rpc calls
// options.select: function (ev, {item: {facet:facet}}). Autocomplete widget will call
// that function when a selection is made by the user
// options.get_search_string: function (). This function will be called by autocomplete
// to obtain the current search string.
init: function (parent, options) {
this._super(parent);
this.$input = parent.$el;
this.source = options.source;
this.delay = options.delay;
this.select = options.select,
this.get_search_string = options.get_search_string;
this.width = options.width || 400;
this.current_result = null;
this.searching = true;
this.search_string = null;
this.current_search = null;
},
start: function () {
var self = this;
this.$el.width(this.width);
this.$input.on('keyup', function (ev) {
if (ev.which === $.ui.keyCode.RIGHT) {
self.searching = true;
ev.preventDefault();
return;
}
if (!self.searching) {
self.searching = true;
return;
}
self.search_string = self.get_search_string();
if (self.search_string.length) {
var search_string = self.search_string;
setTimeout(function () { self.initiate_search(search_string);}, self.delay);
} else {
self.close();
}
});
this.$input.on('keydown', function (ev) {
switch (ev.which) {
case $.ui.keyCode.TAB:
case $.ui.keyCode.ENTER:
if (self.get_search_string().length) {
self.select_item(ev);
}
break;
case $.ui.keyCode.DOWN:
self.move('down');
self.searching = false;
ev.preventDefault();
break;
case $.ui.keyCode.UP:
self.move('up');
self.searching = false;
ev.preventDefault();
break;
case $.ui.keyCode.RIGHT:
self.searching = false;
var current = self.current_result
if (current && current.expand && !current.expanded) {
self.expand();
self.searching = true;
}
ev.preventDefault();
break;
case $.ui.keyCode.ESCAPE:
self.close();
self.searching = false;
break;
}
});
},
initiate_search: function (query) {
if (query === this.search_string && query !== this.current_search) {
this.search(query);
}
},
search: function (query) {
var self = this;
this.current_search = query;
this.source({term:query}, function (results) {
if (results.length) {
self.render_search_results(results);
self.focus_element(self.$('li:first-child'));
} else {
self.close();
}
});
},
render_search_results: function (results) {
var self = this;
var $list = this.$('ul');
$list.empty();
results.forEach(function (result) {
var $item = self.make_list_item(result).appendTo($list);
result.$el = $item;
});
this.show();
},
make_list_item: function (result) {
var self = this;
var $li = $('<li>')
.hover(function (ev) {self.focus_element($li);})
.mousedown(function (ev) {
if (ev.button === 0) { // left button
self.select(ev, {item: {facet: result.facet}});
self.close();
} else {
ev.preventDefault();
}
})
.data('result', result);
if (result.expand) {
var $expand = $('<span class="oe-expand">').text('▶').appendTo($li);
$expand.mousedown(function (ev) {
ev.preventDefault();
ev.stopPropagation();
if (result.expanded)
self.fold();
else
self.expand();
});
result.expanded = false;
}
if (result.indent) $li.addClass('oe-indent');
$li.append($('<span>').html(result.label));
return $li;
},
expand: function () {
var self = this;
this.current_result.expand(this.get_search_string()).then(function (results) {
(results || [{label: '(no result)'}]).forEach(function (result) {
result.indent = true;
var $li = self.make_list_item(result);
self.current_result.$el.after($li);
});
self.current_result.expanded = true;
self.current_result.$el.find('span.oe-expand').html('▼');
});
},
fold: function () {
var $next = this.current_result.$el.next();
while ($next.hasClass('oe-indent')) {
$next.remove();
$next = this.current_result.$el.next();
}
this.current_result.expanded = false;
this.current_result.$el.find('span.oe-expand').html('▶');
},
focus_element: function ($li) {
this.$('li').removeClass('oe-selection-focus');
$li.addClass('oe-selection-focus');
this.current_result = $li.data('result');
},
select_item: function (ev) {
if (this.current_result.facet) {
this.select(ev, {item: {facet: this.current_result.facet}});
this.close();
}
},
show: function () {
this.$el.show();
},
close: function () {
this.current_search = null;
this.search_string = null;
this.searching = true;
this.$el.hide();
},
move: function (direction) {
var $next;
if (direction === 'down') {
$next = this.$('li.oe-selection-focus').next();
if (!$next.length) $next = this.$('li:first-child');
} else {
$next = this.$('li.oe-selection-focus').prev();
if (!$next.length) $next = this.$('li:last-child');
}
this.focus_element($next);
},
is_expandable: function () {
return !!this.$('.oe-selection-focus .oe-expand').length;
},
});
})();
// vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax:

View File

@ -1802,6 +1802,10 @@
</select>
</t>
<div t-name="SearchView.autocomplete" class="oe-autocomplete">
<ul>
</ul>
</div>
<t t-name="ExportView">
<a id="exportview" href="javascript: void(0)" style="text-decoration: none;color: #3D3D3D;">Export</a>
</t>

View File

@ -558,7 +558,20 @@ openerp.testing.section('search.completions', {
new Date(2012, 4, 21, 21, 21, 21).getTime());
});
});
test("M2O", {asserts: 13}, function (instance, $s, mock) {
test("M2O complete", {asserts: 4}, function (instance, $s, mock) {
var view = {inputs: [], dataset: {get_context: function () {}}};
var f = new instance.web.search.ManyToOneField(
{attrs: {string: 'Dummy'}}, {relation: 'dummy.model'}, view);
return f.complete("bob")
.done(function (c) {
equal(c.length, 1, "should return one line");
var bob = c[0];
ok(bob.expand, "should return an expand callback");
ok(bob.facet, "should have a facet");
ok(bob.label, "should have a label");
});
});
test("M2O expand", {asserts: 13}, function (instance, $s, mock) {
mock('dummy.model:name_search', function (args, kwargs) {
deepEqual(args, []);
strictEqual(kwargs.name, 'bob');
@ -568,7 +581,7 @@ openerp.testing.section('search.completions', {
var view = {inputs: [], dataset: {get_context: function () {}}};
var f = new instance.web.search.ManyToOneField(
{attrs: {string: 'Dummy'}}, {relation: 'dummy.model'}, view);
return f.complete("bob")
return f.expand("bob")
.done(function (c) {
equal(c.length, 3, "should return results + title");
var title = c[0];
@ -597,7 +610,7 @@ openerp.testing.section('search.completions', {
var view = {inputs: [], dataset: {get_context: function () {}}};
var f = new instance.web.search.ManyToOneField(
{attrs: {string: 'Dummy'}}, {relation: 'dummy.model'}, view);
return f.complete("bob")
return f.expand("bob")
.done(function (c) {
ok(!c, "no match should yield no completion");
});
@ -620,7 +633,7 @@ openerp.testing.section('search.completions', {
var f = new instance.web.search.ManyToOneField(
{attrs: {string: 'Dummy', domain: '[["foo", "=", "bar"]]'}},
{relation: 'dummy.model'}, view);
return f.complete("bob");
return f.expand("bob");
});
test("M2O custom operator", {asserts: 10}, function (instance, $s, mock) {
mock('dummy.model:name_search', function (args, kwargs) {
@ -635,7 +648,7 @@ openerp.testing.section('search.completions', {
{attrs: {string: 'Dummy', operator: 'ilike'}},
{relation: 'dummy.model'}, view);
return f.complete('bob')
return f.expand('bob')
.done(function (c) {
equal(c.length, 2, "should return result + title");
var title = c[0];