[MERGE] web search lazy autocompletion by ged

Search many2one only when they are slected
Remove dependecy on jquery.ui automcomplete.
This commit is contained in:
Antony Lesuisse 2014-06-30 18:32:24 +02:00
commit 8de012946b
5 changed files with 369 additions and 87 deletions

View File

@ -1564,6 +1564,46 @@
-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 .oe-autocomplete ul li.oe-separator {
margin-top: 2px;
margin-bottom: 2px;
border-top: 1px solid #afafb6;
}
.openerp .oe_searchview .oe-autocomplete ul li.oe-separator:last-child {
display: none;
}
.openerp .oe_searchview_drawer_container {
overflow: auto;
}

View File

@ -1289,6 +1289,39 @@ $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
li.oe-separator
margin-top: 2px
margin-bottom: 2px
border-top: 1px solid #afafb6
li.oe-separator:last-child
display: none
.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`)
@ -546,12 +513,6 @@ instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.Sea
.value()).then(function () {
resp(_(arguments).chain()
.compact()
.map(function (completion) {
if (completion.length && completion[0].facet !== undefined) {
completion[0].first = true;
}
return completion;
})
.flatten(true)
.value());
});
@ -579,14 +540,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.
@ -890,8 +846,10 @@ instance.web.SearchViewDrawer = instance.web.Widget.extend({
filters.push(new instance.web.search.Filter(item, group));
break;
case 'group':
self.add_separator();
self.make_widgets(item.children, fields,
new instance.web.search.Group(group, 'w', item));
self.add_separator();
break;
case 'field':
var field = this.make_field(
@ -907,6 +865,11 @@ instance.web.SearchViewDrawer = instance.web.Widget.extend({
group.push(new instance.web.search.FilterGroup(filters, this));
}
},
add_separator: function () {
if (!(_.last(this.inputs) instanceof instance.web.search.Separator))
new instance.web.search.Separator(this);
},
/**
* Creates a field for the provided field descriptor item (which comes
* from fields_view_get)
@ -1349,6 +1312,15 @@ instance.web.search.Filter = instance.web.search.Input.extend(/** @lends instanc
get_context: function () { },
get_domain: function () { },
});
instance.web.search.Separator = instance.web.search.Input.extend({
_in_drawer: false,
complete: function () {
return {is_separator: true};
}
});
instance.web.search.Field = instance.web.search.Input.extend( /** @lends instance.web.search.Field# */ {
template: 'SearchView.field',
default_operator: '=',
@ -1639,7 +1611,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(
@ -1652,13 +1642,12 @@ instance.web.search.ManyToOneField = instance.web.search.CharField.extend({
context: context
}).then(function (results) {
if (_.isEmpty(results)) { return null; }
return [{label: self.attrs.string}].concat(
_(results).map(function (result) {
return {
label: _.escape(result[1]),
facet: facet_from(self, result)
};
}));
return _(results).map(function (result) {
return {
label: _.escape(result[1]),
facet: facet_from(self, result)
};
});
});
},
facet_for: function (value) {
@ -2311,6 +2300,213 @@ 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();
var render_separator = false;
results.forEach(function (result) {
if (result.is_separator) {
if (render_separator)
$list.append($('<li>').addClass('oe-separator'));
render_separator = false;
} else {
var $item = self.make_list_item(result).appendTo($list);
result.$el = $item;
render_separator = true;
}
});
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)'}]).reverse().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').nextAll(':not(.oe-separator)').first();
if (!$next.length) $next = this.$('li:first-child');
} else {
$next = this.$('li.oe-selection-focus').prevAll(':not(.oe-separator)').first();
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

@ -1720,6 +1720,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: 11}, function (instance, $s, mock) {
mock('dummy.model:name_search', function (args, kwargs) {
deepEqual(args, []);
strictEqual(kwargs.name, 'bob');
@ -568,21 +581,18 @@ 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];
equal(title.label, f.attrs.string, "title should match field name");
ok(!title.facet, "title should not have a facet");
equal(c.length, 2, "should return results");
var f1 = new instance.web.search.Facet(c[1].facet);
equal(c[1].label, "choice 1");
var f1 = new instance.web.search.Facet(c[0].facet);
equal(c[0].label, "choice 1");
equal(f1.get('category'), f.attrs.string);
equal(f1.get('field'), f);
deepEqual(f1.values.toJSON(), [{label: 'choice 1', value: 42}]);
var f2 = new instance.web.search.Facet(c[2].facet);
equal(c[2].label, "choice @");
var f2 = new instance.web.search.Facet(c[1].facet);
equal(c[1].label, "choice @");
equal(f2.get('category'), f.attrs.string);
equal(f2.get('field'), f);
deepEqual(f2.values.toJSON(), [{label: 'choice @', value: 43}]);
@ -597,7 +607,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,9 +630,9 @@ 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) {
test("M2O custom operator", {asserts: 8}, function (instance, $s, mock) {
mock('dummy.model:name_search', function (args, kwargs) {
deepEqual(args, [], "should have no positional arguments");
// the operator is meant for the final search term generation, not the autocompletion
@ -635,15 +645,12 @@ 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];
equal(title.label, f.attrs.string, "title should match field name");
ok(!title.facet, "title should not have a facet");
equal(c.length, 1, "should return result");
var f1 = new instance.web.search.Facet(c[1].facet);
equal(c[1].label, "Match");
var f1 = new instance.web.search.Facet(c[0].facet);
equal(c[0].label, "Match");
equal(f1.get('category'), f.attrs.string);
equal(f1.get('field'), f);
deepEqual(f1.values.toJSON(), [{label: 'Match', value: 42}]);
@ -1210,13 +1217,14 @@ openerp.testing.section('search.groupby', {
var view = makeSearchView(instance);
return view.appendTo($fix)
.done(function () {
// 3 filters, 3 filtergroups, 1 custom filter, 1 advanced, 1 Filters
// 3 filters, 3 filtergroups, 1 custom filter, 1 advanced, 1 Filters,
// and 1 SaveFilter widget
equal(view.drawer.inputs.length, 10, "should have 10 inputs total");
var groups = _.filter(view.drawer.inputs, function (f) {
return f instanceof instance.web.search.GroupbyGroup;
});
equal(groups.length, 3, "should have 3 GroupbyGroups");
groups[0].toggle(groups[0].filters[0]);
@ -1549,10 +1557,11 @@ openerp.testing.section('search.invisible', {
var done = $.Deferred();
view.complete_global_search({term: 'filter'}, function (compls) {
done.resolve();
strictEqual(compls.length, 2,
"should have 2 completions");
console.log("completions", compls);
strictEqual(compls.length, 5,
"should have 5 completions"); // 2 filters and 3 separators
deepEqual(_.pluck(compls, 'label'),
['Field 0', 'Filter on: Filter 0'],
[undefined, 'Field 0', 'Filter on: Filter 0', undefined, undefined],
"should complete on field 0 and filter 0");
});
return done;