diff --git a/addons/web/static/src/css/base.css b/addons/web/static/src/css/base.css index 10ca4d89f25..a52cfb22c78 100644 --- a/addons/web/static/src/css/base.css +++ b/addons/web/static/src/css/base.css @@ -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; } diff --git a/addons/web/static/src/css/base.sass b/addons/web/static/src/css/base.sass index 5aa1b2ebf50..76e56b249cf 100644 --- a/addons/web/static/src/css/base.sass +++ b/addons/web/static/src/css/base.sass @@ -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 diff --git a/addons/web/static/src/js/search.js b/addons/web/static/src/js/search.js index ac441d8eed6..0ec63b890e9 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`) @@ -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: '' + _.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( @@ -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($('
  • ').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 = $('
  • ') + .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)'}]).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: diff --git a/addons/web/static/src/xml/base.xml b/addons/web/static/src/xml/base.xml index 485e92e1f7d..adae3be7c63 100644 --- a/addons/web/static/src/xml/base.xml +++ b/addons/web/static/src/xml/base.xml @@ -1720,6 +1720,10 @@ +
    +
      +
    +
    Export diff --git a/addons/web/static/test/search.js b/addons/web/static/test/search.js index 4724ca0792d..5faf13b2599 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: 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;