From 0a4a7f3d20e3cce435824d3aa756346088b0c73b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9ry=20Debongnie?= Date: Wed, 25 Jun 2014 13:26:41 +0200 Subject: [PATCH] [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) --- addons/web/static/src/css/base.css | 32 ++++ addons/web/static/src/css/base.sass | 25 +++ addons/web/static/src/js/search.js | 279 +++++++++++++++++++++++----- addons/web/static/src/xml/base.xml | 4 + addons/web/static/test/search.js | 23 ++- 5 files changed, 308 insertions(+), 55 deletions(-) diff --git a/addons/web/static/src/css/base.css b/addons/web/static/src/css/base.css index 91146789602..dab2e482a18 100644 --- a/addons/web/static/src/css/base.css +++ b/addons/web/static/src/css/base.css @@ -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; } diff --git a/addons/web/static/src/css/base.sass b/addons/web/static/src/css/base.sass index 07d683268f1..523ee846622 100644 --- a/addons/web/static/src/css/base.sass +++ b/addons/web/static/src/css/base.sass @@ -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 diff --git a/addons/web/static/src/js/search.js b/addons/web/static/src/js/search.js index ac441d8eed6..b0b73d28e4f 100644 --- a/addons/web/static/src/js/search.js +++ b/addons/web/static/src/js/search.js @@ -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 = $( "
  • " ) - .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) - ? $('').html(item.label) - : $('').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: '' + _.escape(this.attrs.string) + '', + value: '' + _.escape(value) + ''}); + 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 = $('
  • ') + .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 = $('').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($('').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: diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 69af5cbe91d..1caef25a907 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -1802,6 +1802,10 @@ +
    +
      +
    +
    Export diff --git a/addons/web/static/test/search.js b/addons/web/static/test/search.js index 4724ca0792d..d6490abee22 100644 --- a/addons/web/static/test/search.js +++ b/addons/web/static/test/search.js @@ -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];