[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:
parent
1eaa69d342
commit
0a4a7f3d20
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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];
|
||||
|
|
Loading…
Reference in New Issue