[FIX] web: correct invalid instruction introduced during previous forward-port
[odoo/odoo.git] / addons / web / static / src / js / search.js
1
2 (function() {
3
4 var instance = openerp;
5 openerp.web.search = {};
6
7 var QWeb = instance.web.qweb,
8       _t =  instance.web._t,
9      _lt = instance.web._lt;
10
11 /** @namespace */
12 var my = instance.web.search = {};
13
14 var B = Backbone;
15 my.FacetValue = B.Model.extend({
16
17 });
18 my.FacetValues = B.Collection.extend({
19     model: my.FacetValue
20 });
21 my.Facet = B.Model.extend({
22     initialize: function (attrs) {
23         var values = attrs.values;
24         delete attrs.values;
25
26         B.Model.prototype.initialize.apply(this, arguments);
27
28         this.values = new my.FacetValues(values || []);
29         this.values.on('add remove change reset', function (_, options) {
30             this.trigger('change', this, options);
31         }, this);
32     },
33     get: function (key) {
34         if (key !== 'values') {
35             return B.Model.prototype.get.call(this, key);
36         }
37         return this.values.toJSON();
38     },
39     set: function (key, value) {
40         if (key !== 'values') {
41             return B.Model.prototype.set.call(this, key, value);
42         }
43         this.values.reset(value);
44     },
45     toJSON: function () {
46         var out = {};
47         var attrs = this.attributes;
48         for(var att in attrs) {
49             if (!attrs.hasOwnProperty(att) || att === 'field') {
50                 continue;
51             }
52             out[att] = attrs[att];
53         }
54         out.values = this.values.toJSON();
55         return out;
56     }
57 });
58 my.SearchQuery = B.Collection.extend({
59     model: my.Facet,
60     initialize: function () {
61         B.Collection.prototype.initialize.apply(
62             this, arguments);
63         this.on('change', function (facet) {
64             if(!facet.values.isEmpty()) { return; }
65
66             this.remove(facet, {silent: true});
67         }, this);
68     },
69     add: function (values, options) {
70         options = options || {};
71
72         if (!values) {
73             values = [];
74         } else if (!(values instanceof Array)) {
75             values = [values];
76         }
77
78         _(values).each(function (value) {
79             var model = this._prepareModel(value, options);
80             var previous = this.detect(function (facet) {
81                 return facet.get('category') === model.get('category')
82                     && facet.get('field') === model.get('field');
83             });
84             if (previous) {
85                 previous.values.add(model.get('values'), _.omit(options, 'at', 'merge'));
86                 return;
87             }
88             B.Collection.prototype.add.call(this, model, options);
89         }, this);
90         // warning: in backbone 1.0+ add is supposed to return the added models,
91         // but here toggle may delegate to add and return its value directly.
92         // return value of neither seems actually used but should be tested
93         // before change, probably
94         return this;
95     },
96     toggle: function (value, options) {
97         options = options || {};
98
99         var facet = this.detect(function (facet) {
100             return facet.get('category') === value.category
101                 && facet.get('field') === value.field;
102         });
103         if (!facet) {
104             return this.add(value, options);
105         }
106
107         var changed = false;
108         _(value.values).each(function (val) {
109             var already_value = facet.values.detect(function (v) {
110                 return v.get('value') === val.value
111                     && v.get('label') === val.label;
112             });
113             // toggle value
114             if (already_value) {
115                 facet.values.remove(already_value, {silent: true});
116             } else {
117                 facet.values.add(val, {silent: true});
118             }
119             changed = true;
120         });
121         // "Commit" changes to values array as a single call, so observers of
122         // change event don't get misled by intermediate incomplete toggling
123         // states
124         facet.trigger('change', facet);
125         return this;
126     }
127 });
128
129 function assert(condition, message) {
130     if(!condition) {
131         throw new Error(message);
132     }
133 }
134 my.InputView = instance.web.Widget.extend({
135     template: 'SearchView.InputView',
136     events: {
137         focus: function () { this.trigger('focused', this); },
138         blur: function () { this.$el.text(''); this.trigger('blurred', this); },
139         keydown: 'onKeydown',
140         paste: 'onPaste',
141     },
142     getSelection: function () {
143         this.el.normalize();
144         // get Text node
145         var root = this.el.childNodes[0];
146         if (!root || !root.textContent) {
147             // if input does not have a child node, or the child node is an
148             // empty string, then the selection can only be (0, 0)
149             return {start: 0, end: 0};
150         }
151         var range = window.getSelection().getRangeAt(0);
152         // In Firefox, depending on the way text is selected (drag, double- or
153         // triple-click) the range may start or end on the parent of the
154         // selected text node‽ Check for this condition and fixup the range
155         // note: apparently with C-a this can go even higher?
156         if (range.startContainer === this.el && range.startOffset === 0) {
157             range.setStart(root, 0);
158         }
159         if (range.endContainer === this.el && range.endOffset === 1) {
160             range.setEnd(root, root.length);
161         }
162         assert(range.startContainer === root,
163                "selection should be in the input view");
164         assert(range.endContainer === root,
165                "selection should be in the input view");
166         return {
167             start: range.startOffset,
168             end: range.endOffset
169         };
170     },
171     onKeydown: function (e) {
172         this.el.normalize();
173         var sel;
174         switch (e.which) {
175         // Do not insert newline, but let it bubble so searchview can use it
176         case $.ui.keyCode.ENTER:
177             e.preventDefault();
178             break;
179
180         // FIXME: may forget content if non-empty but caret at index 0, ok?
181         case $.ui.keyCode.BACKSPACE:
182             sel = this.getSelection();
183             if (sel.start === 0 && sel.start === sel.end) {
184                 e.preventDefault();
185                 var preceding = this.getParent().siblingSubview(this, -1);
186                 if (preceding && (preceding instanceof my.FacetView)) {
187                     preceding.model.destroy();
188                 }
189             }
190             break;
191
192         // let left/right events propagate to view if caret is at input border
193         // and not a selection
194         case $.ui.keyCode.LEFT:
195             sel = this.getSelection();
196             if (sel.start !== 0 || sel.start !== sel.end) {
197                 e.stopPropagation();
198             }
199             break;
200         case $.ui.keyCode.RIGHT:
201             sel = this.getSelection();
202             var len = this.$el.text().length;
203             if (sel.start !== len || sel.start !== sel.end) {
204                 e.stopPropagation();
205             }
206             break;
207         }
208     },
209     setCursorAtEnd: function () {
210         this.el.normalize();
211         var sel = window.getSelection();
212         sel.removeAllRanges();
213         var range = document.createRange();
214         // in theory, range.selectNodeContents should work here. In practice,
215         // MSIE9 has issues from time to time, instead of selecting the inner
216         // text node it would select the reference node instead (e.g. in demo
217         // data, company news, copy across the "Company News" link + the title,
218         // from about half the link to half the text, paste in search box then
219         // hit the left arrow key, getSelection would blow up).
220         //
221         // Explicitly selecting only the inner text node (only child node
222         // since we've normalized the parent) avoids the issue
223         range.selectNode(this.el.childNodes[0]);
224         range.collapse(false);
225         sel.addRange(range);
226     },
227     onPaste: function () {
228         this.el.normalize();
229         // In MSIE and Webkit, it is possible to get various representations of
230         // the clipboard data at this point e.g.
231         // window.clipboardData.getData('Text') and
232         // event.clipboardData.getData('text/plain') to ensure we have a plain
233         // text representation of the object (and probably ensure the object is
234         // pastable as well, so nobody puts an image in the search view)
235         // (nb: since it's not possible to alter the content of the clipboard
236         // — at least in Webkit — to ensure only textual content is available,
237         // using this would require 1. getting the text data; 2. manually
238         // inserting the text data into the content; and 3. cancelling the
239         // paste event)
240         //
241         // But Firefox doesn't support the clipboard API (as of FF18)
242         // although it correctly triggers the paste event (Opera does not even
243         // do that) => implement lowest-denominator system where onPaste
244         // triggers a followup "cleanup" pass after the data has been pasted
245         setTimeout(function () {
246             // Read text content (ignore pasted HTML)
247             var data = this.$el.text();
248             if (!data)
249                 return; 
250             // paste raw text back in
251             this.$el.empty().text(data);
252             this.el.normalize();
253             // Set the cursor at the end of the text, so the cursor is not lost
254             // in some kind of error-spawning limbo.
255             this.setCursorAtEnd();
256         }.bind(this), 0);
257     }
258 });
259 my.FacetView = instance.web.Widget.extend({
260     template: 'SearchView.FacetView',
261     events: {
262         'focus': function () { this.trigger('focused', this); },
263         'blur': function () { this.trigger('blurred', this); },
264         'click': function (e) {
265             if ($(e.target).is('.oe_facet_remove')) {
266                 this.model.destroy();
267                 return false;
268             }
269             this.$el.focus();
270             e.stopPropagation();
271         },
272         'keydown': function (e) {
273             var keys = $.ui.keyCode;
274             switch (e.which) {
275             case keys.BACKSPACE:
276             case keys.DELETE:
277                 this.model.destroy();
278                 return false;
279             }
280         }
281     },
282     init: function (parent, model) {
283         this._super(parent);
284         this.model = model;
285         this.model.on('change', this.model_changed, this);
286     },
287     destroy: function () {
288         this.model.off('change', this.model_changed, this);
289         this._super();
290     },
291     start: function () {
292         var self = this;
293         var $e = this.$('> span:last-child');
294         return $.when(this._super()).then(function () {
295             return $.when.apply(null, self.model.values.map(function (value) {
296                 return new my.FacetValueView(self, value).appendTo($e);
297             }));
298         });
299     },
300     model_changed: function () {
301         this.$el.text(this.$el.text() + '*');
302     }
303 });
304 my.FacetValueView = instance.web.Widget.extend({
305     template: 'SearchView.FacetView.Value',
306     init: function (parent, model) {
307         this._super(parent);
308         this.model = model;
309         this.model.on('change', this.model_changed, this);
310     },
311     destroy: function () {
312         this.model.off('change', this.model_changed, this);
313         this._super();
314     },
315     model_changed: function () {
316         this.$el.text(this.$el.text() + '*');
317     }
318 });
319
320 instance.web.SearchView = instance.web.Widget.extend(/** @lends instance.web.SearchView# */{
321     template: "SearchView",
322     events: {
323         // focus last input if view itself is clicked
324         'click': function (e) {
325             if (e.target === this.$('.oe_searchview_facets')[0]) {
326                 this.$('.oe_searchview_input:last').focus();
327             }
328         },
329         // search button
330         'click div.oe_searchview_search': function (e) {
331             e.stopImmediatePropagation();
332             this.do_search();
333         },
334         'click .oe_searchview_unfold_drawer': function (e) {
335             e.stopImmediatePropagation();
336             $(e.target).toggleClass('fa-caret-down fa-caret-up');
337             localStorage.visible_search_menu = (localStorage.visible_search_menu !== 'true');
338             this.toggle_buttons();
339         },
340         'keydown .oe_searchview_input, .oe_searchview_facet': function (e) {
341             switch(e.which) {
342             case $.ui.keyCode.LEFT:
343                 this.focusPreceding(e.target);
344                 e.preventDefault();
345                 break;
346             case $.ui.keyCode.RIGHT:
347                 if (!this.autocomplete.is_expandable()) {
348                     this.focusFollowing(e.target);
349                 }
350                 e.preventDefault();
351                 break;
352             }
353         },
354         'autocompleteopen': function () {
355             this.$el.autocomplete('widget').css('z-index', 9999);
356         },
357     },
358     /**
359      * @constructs instance.web.SearchView
360      * @extends instance.web.Widget
361      *
362      * @param parent
363      * @param dataset
364      * @param view_id
365      * @param defaults
366      * @param {Object} [options]
367      * @param {Boolean} [options.hidden=false] hide the search view
368      * @param {Boolean} [options.disable_custom_filters=false] do not load custom filters from ir.filters
369      */
370     init: function(parent, dataset, view_id, defaults, options) {
371         this.options = _.defaults(options || {}, {
372             hidden: false,
373             disable_custom_filters: false,
374         });
375         this._super(parent);
376         this.query = undefined;   
377         this.dataset = dataset;
378         this.view_id = view_id;
379         this.search_fields = [];
380         this.filters = [];
381         this.groupbys = [];
382         this.visible_filters = (localStorage.visible_search_menu === 'true');
383         this.input_subviews = []; // for user input in searchbar
384         this.defaults = defaults || {};
385         this.headless = this.options.hidden &&  _.isEmpty(this.defaults);
386         this.$buttons = this.options.$buttons;
387
388         this.filter_menu = undefined;
389         this.groupby_menu = undefined;
390         this.favorite_menu = undefined;
391         this.action_id = this.options && this.options.action && this.options.action.id;
392     },    
393     start: function() {
394         if (this.headless) {
395             this.$el.hide();
396         }
397         this.toggle_visibility(false);
398         this.$facets_container = this.$('div.oe_searchview_facets');
399         this.setup_global_completion();
400         this.query = new my.SearchQuery()
401                 .on('add change reset remove', this.proxy('do_search'))
402                 .on('change', this.proxy('renderChangedFacets'))
403                 .on('add reset remove', this.proxy('renderFacets'));
404         var load_view = instance.web.fields_view_get({
405             model: this.dataset._model,
406             view_id: this.view_id,
407             view_type: 'search',
408             context: this.dataset.get_context(),
409         });
410         this.$('.oe_searchview_unfold_drawer')
411             .toggleClass('fa-caret-down', !this.visible_filters)
412             .toggleClass('fa-caret-up', this.visible_filters);
413         return this.alive($.when(this._super(), load_view.then(this.view_loaded.bind(this))));
414     },
415     view_loaded: function (r) {
416         var self = this;
417         this.fields_view_get = r;
418         this.view_id = this.view_id || r.view_id;
419         this.prepare_search_inputs();
420         if (this.$buttons) {
421
422             var fields_def = new instance.web.Model(this.dataset.model).call('fields_get', {
423                     context: this.dataset.context
424                 });
425
426             this.groupby_menu = new my.GroupByMenu(this, this.groupbys, fields_def);
427             this.filter_menu = new my.FilterMenu(this, this.filters, fields_def);
428             this.favorite_menu = new my.FavoriteMenu(this, this.query, this.dataset.model, this.action_id);
429
430             this.filter_menu.appendTo(this.$buttons);
431             this.groupby_menu.appendTo(this.$buttons);
432             var custom_filters_ready = this.favorite_menu.appendTo(this.$buttons);
433         }
434         return $.when(custom_filters_ready).then(this.proxy('set_default_filters'));
435     },
436     // it should parse the arch field of the view, instantiate the corresponding 
437     // filters/fields, and put them in the correct variables:
438     // * this.search_fields is a list of all the fields,
439     // * this.filters: groups of filters
440     // * this.group_by: group_bys
441     prepare_search_inputs: function () {
442         var self = this,
443             arch = this.fields_view_get.arch;
444
445         var filters = [].concat.apply([], _.map(arch.children, function (item) {
446             return item.tag !== 'group' ? eval_item(item) : item.children.map(eval_item);
447         }));
448         function eval_item (item) {
449             var category = 'filters';
450             if (item.attrs.context) {
451                 try {
452                     var context = instance.web.pyeval.eval('context', item.attrs.context);
453                     if (context.group_by) {
454                         category = 'group_by';
455                     }                    
456                 } catch (e) {}
457             }
458             return {
459                 item: item,
460                 category: category,
461             }
462         }
463         var current_group = [],
464             current_category = 'filters',
465             categories = {filters: this.filters, group_by: this.groupbys};
466
467         _.each(filters.concat({category:'filters', item: 'separator'}), function (filter) {
468             if (filter.item.tag === 'filter' && filter.category === current_category) {
469                 return current_group.push(new my.Filter(filter.item, self));
470             }
471             if (current_group.length) {
472                 var group = new my.FilterGroup(current_group, self);
473                 categories[current_category].push(group);
474                 current_group = [];
475             }
476             if (filter.item.tag === 'field') {
477                 var attrs = filter.item.attrs,
478                     field = self.fields_view_get.fields[attrs.name],
479                     Obj = my.fields.get_any([attrs.widget, field.type]);
480                 if (Obj) {
481                     self.search_fields.push(new (Obj) (filter.item, field, self));
482                 }
483             }
484             if (filter.item.tag === 'filter') {
485                 current_group.push(new my.Filter(filter.item, self));
486             }
487             current_category = filter.category;
488         });
489     },
490     set_default_filters: function () {
491         var self = this,
492             default_custom_filter = this.$buttons && this.favorite_menu.get_default_filter();
493         if (default_custom_filter) {
494             return this.favorite_menu.toggle_filter(default_custom_filter, true);
495         }
496         if (!_.isEmpty(this.defaults)) {
497             var inputs = this.search_fields.concat(this.filters, this.groupbys),
498                 defaults = _.invoke(inputs, 'facet_for_defaults', this.defaults);
499             return $.when.apply(null, defaults).then(function () {
500                 self.query.reset(_(arguments).compact(), {preventSearch: true});
501             });
502         } 
503         this.query.reset([], {preventSearch: true});
504         return $.when();
505     },
506     /**
507      * Performs the search view collection of widget data.
508      *
509      * If the collection went well (all fields are valid), then triggers
510      * :js:func:`instance.web.SearchView.on_search`.
511      *
512      * If at least one field failed its validation, triggers
513      * :js:func:`instance.web.SearchView.on_invalid` instead.
514      *
515      * @param [_query]
516      * @param {Object} [options]
517      */
518     do_search: function (_query, options) {
519         if (options && options.preventSearch) {
520             return;
521         }
522         var search = this.build_search_data();
523         this.trigger('search_data', search.domains, search.contexts, search.groupbys);
524     },
525     /**
526      * Extract search data from the view's facets.
527      *
528      * Result is an object with 3 (own) properties:
529      *
530      * domains
531      *     Array of domains
532      * contexts
533      *     Array of contexts
534      * groupbys
535      *     Array of domains, in groupby order rather than view order
536      *
537      * @return {Object}
538      */
539     build_search_data: function () {
540         var domains = [], contexts = [], groupbys = [];
541
542         this.query.each(function (facet) {
543             var field = facet.get('field');
544             var domain = field.get_domain(facet);
545             if (domain) {
546                 domains.push(domain);
547             }
548             var context = field.get_context(facet);
549             if (context) {
550                 contexts.push(context);
551             }
552             var group_by = field.get_groupby(facet);
553             if (group_by) {
554                 groupbys.push.apply(groupbys, group_by);
555             }
556         });
557         return {
558             domains: domains,
559             contexts: contexts,
560             groupbys: groupbys,
561         };
562     }, 
563     toggle_visibility: function (is_visible) {
564         this.$el.toggle(!this.headless && is_visible);
565         this.$buttons && this.$buttons.toggle(!this.headless && is_visible && this.visible_filters);
566     },
567     toggle_buttons: function (is_visible) {
568         this.visible_filters = is_visible || !this.visible_filters;
569         this.$buttons && this.$buttons.toggle(this.visible_filters);
570     },
571     /**
572      * Sets up search view's view-wide auto-completion widget
573      */
574     setup_global_completion: function () {
575         var self = this;
576         this.autocomplete = new my.AutoComplete(this, {
577             source: this.proxy('complete_global_search'),
578             select: this.proxy('select_completion'),
579             delay: 0,
580             get_search_string: function () {
581                 return self.$('div.oe_searchview_input').text();
582             },
583         });
584         this.autocomplete.appendTo(this.$el);
585     },
586     /**
587      * Provide auto-completion result for req.term (an array to `resp`)
588      *
589      * @param {Object} req request to complete
590      * @param {String} req.term searched term to complete
591      * @param {Function} resp response callback
592      */
593     complete_global_search:  function (req, resp) {
594         var inputs = this.search_fields.concat(this.filters, this.groupbys);
595         $.when.apply(null, _(inputs).chain()
596             .filter(function (input) { return input.visible(); })
597             .invoke('complete', req.term)
598             .value()).then(function () {
599                 resp(_(arguments).chain()
600                     .compact()
601                     .flatten(true)
602                     .value());
603                 });
604     },
605     /**
606      * Action to perform in case of selection: create a facet (model)
607      * and add it to the search collection
608      *
609      * @param {Object} e selection event, preventDefault to avoid setting value on object
610      * @param {Object} ui selection information
611      * @param {Object} ui.item selected completion item
612      */
613     select_completion: function (e, ui) {
614         e.preventDefault();
615         var input_index = _(this.input_subviews).indexOf(
616             this.subviewForRoot(
617                 this.$('div.oe_searchview_input:focus')[0]));
618         this.query.add(ui.item.facet, {at: input_index / 2});
619     },
620     subviewForRoot: function (subview_root) {
621         return _(this.input_subviews).detect(function (subview) {
622             return subview.$el[0] === subview_root;
623         });
624     },
625     siblingSubview: function (subview, direction, wrap_around) {
626         var index = _(this.input_subviews).indexOf(subview) + direction;
627         if (wrap_around && index < 0) {
628             index = this.input_subviews.length - 1;
629         } else if (wrap_around && index >= this.input_subviews.length) {
630             index = 0;
631         }
632         return this.input_subviews[index];
633     },
634     focusPreceding: function (subview_root) {
635         return this.siblingSubview(
636             this.subviewForRoot(subview_root), -1, true)
637                 .$el.focus();
638     },
639     focusFollowing: function (subview_root) {
640         return this.siblingSubview(
641             this.subviewForRoot(subview_root), +1, true)
642                 .$el.focus();
643     },
644     /**
645      * @param {openerp.web.search.SearchQuery | undefined} Undefined if event is change
646      * @param {openerp.web.search.Facet} 
647      * @param {Object} [options]
648      */
649     renderFacets: function (collection, model, options) {
650         var self = this;
651         var started = [];
652         _.invoke(this.input_subviews, 'destroy');
653         this.input_subviews = [];
654
655         var i = new my.InputView(this);
656         started.push(i.appendTo(this.$facets_container));
657         this.input_subviews.push(i);
658         this.query.each(function (facet) {
659             var f = new my.FacetView(this, facet);
660             started.push(f.appendTo(self.$facets_container));
661             self.input_subviews.push(f);
662
663             var i = new my.InputView(this);
664             started.push(i.appendTo(self.$facets_container));
665             self.input_subviews.push(i);
666         }, this);
667         _.each(this.input_subviews, function (childView) {
668             childView.on('focused', self, self.proxy('childFocused'));
669             childView.on('blurred', self, self.proxy('childBlurred'));
670         });
671
672         $.when.apply(null, started).then(function () {
673             if (options && options.focus_input === false) return;
674             var input_to_focus;
675             // options.at: facet inserted at given index, focus next input
676             // otherwise just focus last input
677             if (!options || typeof options.at !== 'number') {
678                 input_to_focus = _.last(self.input_subviews);
679             } else {
680                 input_to_focus = self.input_subviews[(options.at + 1) * 2];
681             }
682             input_to_focus.$el.focus();
683         });
684     },
685     childFocused: function () {
686         this.$el.addClass('active');
687     },
688     childBlurred: function () {
689         this.$el.val('').removeClass('active').trigger('blur');
690         this.autocomplete.close();
691     },
692     /**
693      * Call the renderFacets method with the correct arguments.
694      * This is due to the fact that change events are called with two arguments
695      * (model, options) while add, reset and remove events are called with
696      * (collection, model, options) as arguments
697      */
698     renderChangedFacets: function (model, options) {
699         this.renderFacets(undefined, model, options);
700     },
701 });
702
703 /**
704  * Registry of search fields, called by :js:class:`instance.web.SearchView` to
705  * find and instantiate its field widgets.
706  */
707 instance.web.search.fields = new instance.web.Registry({
708     'char': 'instance.web.search.CharField',
709     'text': 'instance.web.search.CharField',
710     'html': 'instance.web.search.CharField',
711     'boolean': 'instance.web.search.BooleanField',
712     'integer': 'instance.web.search.IntegerField',
713     'id': 'instance.web.search.IntegerField',
714     'float': 'instance.web.search.FloatField',
715     'selection': 'instance.web.search.SelectionField',
716     'datetime': 'instance.web.search.DateTimeField',
717     'date': 'instance.web.search.DateField',
718     'many2one': 'instance.web.search.ManyToOneField',
719     'many2many': 'instance.web.search.CharField',
720     'one2many': 'instance.web.search.CharField'
721 });
722
723 instance.web.search.Input = instance.web.Widget.extend( /** @lends instance.web.search.Input# */{
724     /**
725      * @constructs instance.web.search.Input
726      * @extends instance.web.Widget
727      *
728      * @param parent
729      */
730     init: function (parent) {
731         this._super(parent);
732         this.load_attrs({});
733     },
734     /**
735      * Fetch auto-completion values for the widget.
736      *
737      * The completion values should be an array of objects with keys category,
738      * label, value prefixed with an object with keys type=section and label
739      *
740      * @param {String} value value to complete
741      * @returns {jQuery.Deferred<null|Array>}
742      */
743     complete: function (value) {
744         return $.when(null);
745     },
746     /**
747      * Returns a Facet instance for the provided defaults if they apply to
748      * this widget, or null if they don't.
749      *
750      * This default implementation will try calling
751      * :js:func:`instance.web.search.Input#facet_for` if the widget's name
752      * matches the input key
753      *
754      * @param {Object} defaults
755      * @returns {jQuery.Deferred<null|Object>}
756      */
757     facet_for_defaults: function (defaults) {
758         if (!this.attrs ||
759             !(this.attrs.name in defaults && defaults[this.attrs.name])) {
760             return $.when(null);
761         }
762         return this.facet_for(defaults[this.attrs.name]);
763     },
764     get_context: function () {
765         throw new Error(
766             "get_context not implemented for widget " + this.attrs.type);
767     },
768     get_groupby: function () {
769         throw new Error(
770             "get_groupby not implemented for widget " + this.attrs.type);
771     },
772     get_domain: function () {
773         throw new Error(
774             "get_domain not implemented for widget " + this.attrs.type);
775     },
776     load_attrs: function (attrs) {
777         attrs.modifiers = attrs.modifiers ? JSON.parse(attrs.modifiers) : {};
778         this.attrs = attrs;
779     },
780     /**
781      * Returns whether the input is "visible". The default behavior is to
782      * query the ``modifiers.invisible`` flag on the input's description or
783      * view node.
784      *
785      * @returns {Boolean}
786      */
787     visible: function () {
788         return !this.attrs.modifiers.invisible;
789     },
790 });
791 instance.web.search.FilterGroup = instance.web.search.Input.extend(/** @lends instance.web.search.FilterGroup# */{
792     template: 'SearchView.filters',
793     icon: "fa-filter",
794     completion_label: _lt("Filter on: %s"),
795     /**
796      * Inclusive group of filters, creates a continuous "button" with clickable
797      * sections (the normal display for filters is to be a self-contained button)
798      *
799      * @constructs instance.web.search.FilterGroup
800      * @extends instance.web.search.Input
801      *
802      * @param {Array<instance.web.search.Filter>} filters elements of the group
803      * @param {instance.web.SearchView} parent parent in which the filters are contained
804      */
805     init: function (filters, parent) {
806         // If all filters are group_by and we're not initializing a GroupbyGroup,
807         // create a GroupbyGroup instead of the current FilterGroup
808         if (!(this instanceof instance.web.search.GroupbyGroup) &&
809               _(filters).all(function (f) {
810                   if (!f.attrs.context) { return false; }
811                   var c = instance.web.pyeval.eval('context', f.attrs.context);
812                   return !_.isEmpty(c.group_by);})) {
813             return new instance.web.search.GroupbyGroup(filters, parent);
814         }
815         this._super(parent);
816         this.filters = filters;
817         this.searchview = parent;
818         this.searchview.query.on('add remove change reset', this.proxy('search_change'));
819     },
820     start: function () {
821         this.$el.on('click', 'a', this.proxy('toggle_filter'));
822         return $.when(null);
823     },
824     /**
825      * Handles change of the search query: any of the group's filter which is
826      * in the search query should be visually checked in the drawer
827      */
828     search_change: function () {
829         var self = this;
830         var $filters = this.$el.removeClass('selected');
831         var facet = this.searchview.query.find(_.bind(this.match_facet, this));
832         if (!facet) { return; }
833         facet.values.each(function (v) {
834             var i = _(self.filters).indexOf(v.get('value'));
835             if (i === -1) { return; }
836             $filters.filter(function () {
837                 return Number($(this).data('index')) === i;
838             }).addClass('selected');
839         });
840     },
841     /**
842      * Matches the group to a facet, in order to find if the group is
843      * represented in the current search query
844      */
845     match_facet: function (facet) {
846         return facet.get('field') === this;
847     },
848     make_facet: function (values) {
849         return {
850             category: _t("Filter"),
851             icon: this.icon,
852             values: values,
853             field: this
854         };
855     },
856     make_value: function (filter) {
857         return {
858             label: filter.attrs.string || filter.attrs.help || filter.attrs.name,
859             value: filter
860         };
861     },
862     facet_for_defaults: function (defaults) {
863         var self = this;
864         var fs = _(this.filters).chain()
865             .filter(function (f) {
866                 return f.attrs && f.attrs.name && !!defaults[f.attrs.name];
867             }).map(function (f) {
868                 return self.make_value(f);
869             }).value();
870         if (_.isEmpty(fs)) { return $.when(null); }
871         return $.when(this.make_facet(fs));
872     },
873     /**
874      * Fetches contexts for all enabled filters in the group
875      *
876      * @param {openerp.web.search.Facet} facet
877      * @return {*} combined contexts of the enabled filters in this group
878      */
879     get_context: function (facet) {
880         var contexts = facet.values.chain()
881             .map(function (f) { return f.get('value').attrs.context; })
882             .without('{}')
883             .reject(_.isEmpty)
884             .value();
885
886         if (!contexts.length) { return; }
887         if (contexts.length === 1) { return contexts[0]; }
888         return _.extend(new instance.web.CompoundContext(), {
889             __contexts: contexts
890         });
891     },
892     /**
893      * Fetches group_by sequence for all enabled filters in the group
894      *
895      * @param {VS.model.SearchFacet} facet
896      * @return {Array} enabled filters in this group
897      */
898     get_groupby: function (facet) {
899         return  facet.values.chain()
900             .map(function (f) { return f.get('value').attrs.context; })
901             .without('{}')
902             .reject(_.isEmpty)
903             .value();
904     },
905     /**
906      * Handles domains-fetching for all the filters within it: groups them.
907      *
908      * @param {VS.model.SearchFacet} facet
909      * @return {*} combined domains of the enabled filters in this group
910      */
911     get_domain: function (facet) {
912         var domains = facet.values.chain()
913             .map(function (f) { return f.get('value').attrs.domain; })
914             .without('[]')
915             .reject(_.isEmpty)
916             .value();
917
918         if (!domains.length) { return; }
919         if (domains.length === 1) { return domains[0]; }
920         for (var i=domains.length; --i;) {
921             domains.unshift(['|']);
922         }
923         return _.extend(new instance.web.CompoundDomain(), {
924             __domains: domains
925         });
926     },
927     toggle_filter: function (e) {
928         e.stopPropagation();
929         this.toggle(this.filters[Number($(e.target).parent().data('index'))]);
930     },
931     toggle: function (filter, options) {
932         this.searchview.query.toggle(this.make_facet([this.make_value(filter)]), options);
933     },
934     is_visible: function () {
935         return _.some(this.filters, function (filter) {
936             return !filter.attrs.invisible;
937         });
938     },
939     complete: function (item) {
940         var self = this;
941         item = item.toLowerCase();
942         var facet_values = _(this.filters).chain()
943             .filter(function (filter) { return filter.visible(); })
944             .filter(function (filter) {
945                 var at = {
946                     string: filter.attrs.string || '',
947                     help: filter.attrs.help || '',
948                     name: filter.attrs.name || ''
949                 };
950                 var include = _.str.include;
951                 return include(at.string.toLowerCase(), item)
952                     || include(at.help.toLowerCase(), item)
953                     || include(at.name.toLowerCase(), item);
954             })
955             .map(this.make_value)
956             .value();
957         if (_(facet_values).isEmpty()) { return $.when(null); }
958         return $.when(_.map(facet_values, function (facet_value) {
959             return {
960                 label: _.str.sprintf(self.completion_label.toString(),
961                                      _.escape(facet_value.label)),
962                 facet: self.make_facet([facet_value])
963             };
964         }));
965     }
966 });
967 instance.web.search.GroupbyGroup = instance.web.search.FilterGroup.extend({
968     icon: 'fa-bars',
969     completion_label: _lt("Group by: %s"),
970     init: function (filters, parent) {
971         this._super(filters, parent);
972         this.searchview = parent;
973         // Not flanders: facet unicity is handled through the
974         // (category, field) pair of facet attributes. This is all well and
975         // good for regular filter groups where a group matches a facet, but for
976         // groupby we want a single facet. So cheat: add an attribute on the
977         // view which proxies to the first GroupbyGroup, so it can be used
978         // for every GroupbyGroup and still provides the various methods needed
979         // by the search view. Use weirdo name to avoid risks of conflicts
980         if (!this.searchview._s_groupby) {
981             this.searchview._s_groupby = {
982                 help: "See GroupbyGroup#init",
983                 get_context: this.proxy('get_context'),
984                 get_domain: this.proxy('get_domain'),
985                 get_groupby: this.proxy('get_groupby')
986             };
987         }
988     },
989     match_facet: function (facet) {
990         return facet.get('field') === this.searchview._s_groupby;
991     },
992     make_facet: function (values) {
993         return {
994             category: _t("GroupBy"),
995             icon: this.icon,
996             values: values,
997             field: this.searchview._s_groupby
998         };
999     }
1000 });
1001 instance.web.search.Filter = instance.web.search.Input.extend(/** @lends instance.web.search.Filter# */{
1002     template: 'SearchView.filter',
1003     /**
1004      * Implementation of the OpenERP filters (button with a context and/or
1005      * a domain sent as-is to the search view)
1006      *
1007      * Filters are only attributes holder, the actual work (compositing
1008      * domains and contexts, converting between facets and filters) is
1009      * performed by the filter group.
1010      *
1011      * @constructs instance.web.search.Filter
1012      * @extends instance.web.search.Input
1013      *
1014      * @param node
1015      * @param parent
1016      */
1017     init: function (node, parent) {
1018         this._super(parent);
1019         this.load_attrs(node.attrs);
1020     },
1021     facet_for: function () { return $.when(null); },
1022     get_context: function () { },
1023     get_domain: function () { },
1024 });
1025
1026 instance.web.search.Field = instance.web.search.Input.extend( /** @lends instance.web.search.Field# */ {
1027     template: 'SearchView.field',
1028     default_operator: '=',
1029     /**
1030      * @constructs instance.web.search.Field
1031      * @extends instance.web.search.Input
1032      *
1033      * @param view_section
1034      * @param field
1035      * @param parent
1036      */
1037     init: function (view_section, field, parent) {
1038         this._super(parent);
1039         this.load_attrs(_.extend({}, field, view_section.attrs));
1040     },
1041     facet_for: function (value) {
1042         return $.when({
1043             field: this,
1044             category: this.attrs.string || this.attrs.name,
1045             values: [{label: String(value), value: value}]
1046         });
1047     },
1048     value_from: function (facetValue) {
1049         return facetValue.get('value');
1050     },
1051     get_context: function (facet) {
1052         var self = this;
1053         // A field needs a context to send when active
1054         var context = this.attrs.context;
1055         if (_.isEmpty(context) || !facet.values.length) {
1056             return;
1057         }
1058         var contexts = facet.values.map(function (facetValue) {
1059             return new instance.web.CompoundContext(context)
1060                 .set_eval_context({self: self.value_from(facetValue)});
1061         });
1062
1063         if (contexts.length === 1) { return contexts[0]; }
1064
1065         return _.extend(new instance.web.CompoundContext(), {
1066             __contexts: contexts
1067         });
1068     },
1069     get_groupby: function () { },
1070     /**
1071      * Function creating the returned domain for the field, override this
1072      * methods in children if you only need to customize the field's domain
1073      * without more complex alterations or tests (and without the need to
1074      * change override the handling of filter_domain)
1075      *
1076      * @param {String} name the field's name
1077      * @param {String} operator the field's operator (either attribute-specified or default operator for the field
1078      * @param {Number|String} facet parsed value for the field
1079      * @returns {Array<Array>} domain to include in the resulting search
1080      */
1081     make_domain: function (name, operator, facet) {
1082         return [[name, operator, this.value_from(facet)]];
1083     },
1084     get_domain: function (facet) {
1085         if (!facet.values.length) { return; }
1086
1087         var value_to_domain;
1088         var self = this;
1089         var domain = this.attrs['filter_domain'];
1090         if (domain) {
1091             value_to_domain = function (facetValue) {
1092                 return new instance.web.CompoundDomain(domain)
1093                     .set_eval_context({self: self.value_from(facetValue)});
1094             };
1095         } else {
1096             value_to_domain = function (facetValue) {
1097                 return self.make_domain(
1098                     self.attrs.name,
1099                     self.attrs.operator || self.default_operator,
1100                     facetValue);
1101             };
1102         }
1103         var domains = facet.values.map(value_to_domain);
1104
1105         if (domains.length === 1) { return domains[0]; }
1106         for (var i = domains.length; --i;) {
1107             domains.unshift(['|']);
1108         }
1109
1110         return _.extend(new instance.web.CompoundDomain(), {
1111             __domains: domains
1112         });
1113     }
1114 });
1115 /**
1116  * Implementation of the ``char`` OpenERP field type:
1117  *
1118  * * Default operator is ``ilike`` rather than ``=``
1119  *
1120  * * The Javascript and the HTML values are identical (strings)
1121  *
1122  * @class
1123  * @extends instance.web.search.Field
1124  */
1125 instance.web.search.CharField = instance.web.search.Field.extend( /** @lends instance.web.search.CharField# */ {
1126     default_operator: 'ilike',
1127     complete: function (value) {
1128         if (_.isEmpty(value)) { return $.when(null); }
1129         var label = _.str.sprintf(_.str.escapeHTML(
1130             _t("Search %(field)s for: %(value)s")), {
1131                 field: '<em>' + _.escape(this.attrs.string) + '</em>',
1132                 value: '<strong>' + _.escape(value) + '</strong>'});
1133         return $.when([{
1134             label: label,
1135             facet: {
1136                 category: this.attrs.string,
1137                 field: this,
1138                 values: [{label: value, value: value}]
1139             }
1140         }]);
1141     }
1142 });
1143 instance.web.search.NumberField = instance.web.search.Field.extend(/** @lends instance.web.search.NumberField# */{
1144     complete: function (value) {
1145         var val = this.parse(value);
1146         if (isNaN(val)) { return $.when(); }
1147         var label = _.str.sprintf(
1148             _t("Search %(field)s for: %(value)s"), {
1149                 field: '<em>' + _.escape(this.attrs.string) + '</em>',
1150                 value: '<strong>' + _.escape(value) + '</strong>'});
1151         return $.when([{
1152             label: label,
1153             facet: {
1154                 category: this.attrs.string,
1155                 field: this,
1156                 values: [{label: value, value: val}]
1157             }
1158         }]);
1159     },
1160 });
1161 /**
1162  * @class
1163  * @extends instance.web.search.NumberField
1164  */
1165 instance.web.search.IntegerField = instance.web.search.NumberField.extend(/** @lends instance.web.search.IntegerField# */{
1166     error_message: _t("not a valid integer"),
1167     parse: function (value) {
1168         try {
1169             return instance.web.parse_value(value, {'widget': 'integer'});
1170         } catch (e) {
1171             return NaN;
1172         }
1173     }
1174 });
1175 /**
1176  * @class
1177  * @extends instance.web.search.NumberField
1178  */
1179 instance.web.search.FloatField = instance.web.search.NumberField.extend(/** @lends instance.web.search.FloatField# */{
1180     error_message: _t("not a valid number"),
1181     parse: function (value) {
1182         try {
1183             return instance.web.parse_value(value, {'widget': 'float'});
1184         } catch (e) {
1185             return NaN;
1186         }
1187     }
1188 });
1189
1190 /**
1191  * Utility function for m2o & selection fields taking a selection/name_get pair
1192  * (value, name) and converting it to a Facet descriptor
1193  *
1194  * @param {instance.web.search.Field} field holder field
1195  * @param {Array} pair pair value to convert
1196  */
1197 function facet_from(field, pair) {
1198     return {
1199         field: field,
1200         category: field.attrs.string,
1201         values: [{label: pair[1], value: pair[0]}]
1202     };
1203 }
1204
1205 /**
1206  * @class
1207  * @extends instance.web.search.Field
1208  */
1209 instance.web.search.SelectionField = instance.web.search.Field.extend(/** @lends instance.web.search.SelectionField# */{
1210     // This implementation is a basic <select> field, but it may have to be
1211     // altered to be more in line with the GTK client, which uses a combo box
1212     // (~ jquery.autocomplete):
1213     // * If an option was selected in the list, behave as currently
1214     // * If something which is not in the list was entered (via the text input),
1215     //   the default domain should become (`ilike` string_value) but **any
1216     //   ``context`` or ``filter_domain`` becomes falsy, idem if ``@operator``
1217     //   is specified. So at least get_domain needs to be quite a bit
1218     //   overridden (if there's no @value and there is no filter_domain and
1219     //   there is no @operator, return [[name, 'ilike', str_val]]
1220     template: 'SearchView.field.selection',
1221     init: function () {
1222         this._super.apply(this, arguments);
1223         // prepend empty option if there is no empty option in the selection list
1224         this.prepend_empty = !_(this.attrs.selection).detect(function (item) {
1225             return !item[1];
1226         });
1227     },
1228     complete: function (needle) {
1229         var self = this;
1230         var results = _(this.attrs.selection).chain()
1231             .filter(function (sel) {
1232                 var value = sel[0], label = sel[1];
1233                 if (!value) { return false; }
1234                 return label.toLowerCase().indexOf(needle.toLowerCase()) !== -1;
1235             })
1236             .map(function (sel) {
1237                 return {
1238                     label: _.escape(sel[1]),
1239                     facet: facet_from(self, sel)
1240                 };
1241             }).value();
1242         if (_.isEmpty(results)) { return $.when(null); }
1243         return $.when.call(null, [{
1244             label: _.escape(this.attrs.string)
1245         }].concat(results));
1246     },
1247     facet_for: function (value) {
1248         var match = _(this.attrs.selection).detect(function (sel) {
1249             return sel[0] === value;
1250         });
1251         if (!match) { return $.when(null); }
1252         return $.when(facet_from(this, match));
1253     }
1254 });
1255 instance.web.search.BooleanField = instance.web.search.SelectionField.extend(/** @lends instance.web.search.BooleanField# */{
1256     /**
1257      * @constructs instance.web.search.BooleanField
1258      * @extends instance.web.search.BooleanField
1259      */
1260     init: function () {
1261         this._super.apply(this, arguments);
1262         this.attrs.selection = [
1263             [true, _t("Yes")],
1264             [false, _t("No")]
1265         ];
1266     }
1267 });
1268 /**
1269  * @class
1270  * @extends instance.web.search.DateField
1271  */
1272 instance.web.search.DateField = instance.web.search.Field.extend(/** @lends instance.web.search.DateField# */{
1273     value_from: function (facetValue) {
1274         return instance.web.date_to_str(facetValue.get('value'));
1275     },
1276     complete: function (needle) {
1277         var m = moment(needle);
1278         if (!m.isValid()) { return $.when(null); }
1279         var d = m.toDate();
1280         var date_string = instance.web.format_value(d, this.attrs);
1281         var label = _.str.sprintf(_.str.escapeHTML(
1282             _t("Search %(field)s at: %(value)s")), {
1283                 field: '<em>' + _.escape(this.attrs.string) + '</em>',
1284                 value: '<strong>' + date_string + '</strong>'});
1285         return $.when([{
1286             label: label,
1287             facet: {
1288                 category: this.attrs.string,
1289                 field: this,
1290                 values: [{label: date_string, value: d}]
1291             }
1292         }]);
1293     }
1294 });
1295 /**
1296  * Implementation of the ``datetime`` openerp field type:
1297  *
1298  * * Uses the same widget as the ``date`` field type (a simple date)
1299  *
1300  * * Builds a slighly more complex, it's a datetime range (includes time)
1301  *   spanning the whole day selected by the date widget
1302  *
1303  * @class
1304  * @extends instance.web.DateField
1305  */
1306 instance.web.search.DateTimeField = instance.web.search.DateField.extend(/** @lends instance.web.search.DateTimeField# */{
1307     value_from: function (facetValue) {
1308         return instance.web.datetime_to_str(facetValue.get('value'));
1309     }
1310 });
1311 instance.web.search.ManyToOneField = instance.web.search.CharField.extend({
1312     default_operator: {},
1313     init: function (view_section, field, parent) {
1314         this._super(view_section, field, parent);
1315         this.model = new instance.web.Model(this.attrs.relation);
1316         this.searchview = parent;
1317     },
1318
1319     complete: function (value) {
1320         if (_.isEmpty(value)) { return $.when(null); }
1321         var label = _.str.sprintf(_.str.escapeHTML(
1322             _t("Search %(field)s for: %(value)s")), {
1323                 field: '<em>' + _.escape(this.attrs.string) + '</em>',
1324                 value: '<strong>' + _.escape(value) + '</strong>'});
1325         return $.when([{
1326             label: label,
1327             facet: {
1328                 category: this.attrs.string,
1329                 field: this,
1330                 values: [{label: value, value: value, operator: 'ilike'}]
1331             },
1332             expand: this.expand.bind(this),
1333         }]);
1334     },
1335
1336     expand: function (needle) {
1337         var self = this;
1338         // FIXME: "concurrent" searches (multiple requests, mis-ordered responses)
1339         var context = instance.web.pyeval.eval(
1340             'contexts', [this.searchview.dataset.get_context()]);
1341         return this.model.call('name_search', [], {
1342             name: needle,
1343             args: (typeof this.attrs.domain === 'string') ? [] : this.attrs.domain,
1344             limit: 8,
1345             context: context
1346         }).then(function (results) {
1347             if (_.isEmpty(results)) { return null; }
1348             return _(results).map(function (result) {
1349                 return {
1350                     label: _.escape(result[1]),
1351                     facet: facet_from(self, result)
1352                 };
1353             });
1354         });
1355     },
1356     facet_for: function (value) {
1357         var self = this;
1358         if (value instanceof Array) {
1359             if (value.length === 2 && _.isString(value[1])) {
1360                 return $.when(facet_from(this, value));
1361             }
1362             assert(value.length <= 1,
1363                    _t("M2O search fields do not currently handle multiple default values"));
1364             // there are many cases of {search_default_$m2ofield: [id]}, need
1365             // to handle this as if it were a single value.
1366             value = value[0];
1367         }
1368         return this.model.call('name_get', [value]).then(function (names) {
1369             if (_(names).isEmpty()) { return null; }
1370             return facet_from(self, names[0]);
1371         });
1372     },
1373     value_from: function (facetValue) {
1374         return facetValue.get('label');
1375     },
1376     make_domain: function (name, operator, facetValue) {
1377         operator = facetValue.get('operator') || operator;
1378
1379         switch(operator){
1380         case this.default_operator:
1381             return [[name, '=', facetValue.get('value')]];
1382         case 'ilike':
1383             return [[name, 'ilike', facetValue.get('value')]];
1384         case 'child_of':
1385             return [[name, 'child_of', facetValue.get('value')]];
1386         }
1387         return this._super(name, operator, facetValue);
1388     },
1389     get_context: function (facet) {
1390         var values = facet.values;
1391         if (_.isEmpty(this.attrs.context) && values.length === 1) {
1392             var c = {};
1393             var v = values.at(0);
1394             if (v.get('operator') !== 'ilike') {
1395                 c['default_' + this.attrs.name] = v.get('value');
1396             }
1397             return c;
1398         }
1399         return this._super(facet);
1400     }
1401 });
1402
1403 instance.web.search.FilterMenu = instance.web.Widget.extend({
1404     template: 'SearchView.FilterMenu',
1405     events: {
1406         'click .oe-add-filter': function () {
1407             this.toggle_custom_filter_menu();
1408         },
1409         'click li': function (event) {event.stopImmediatePropagation();},
1410         'hidden.bs.dropdown': function () {
1411             this.toggle_custom_filter_menu(false);
1412         },
1413         'click .oe-add-condition': 'append_proposition',
1414         'click .oe-apply-filter': 'commit_search',
1415     },
1416     init: function (parent, filters, fields_def) {
1417         var self = this;
1418         this._super(parent);
1419         this.filters = filters || [];
1420         this.searchview = parent;
1421         this.propositions = [];
1422         this.fields_def = fields_def.then(function (data) {
1423             var fields = {
1424                 id: { string: 'ID', type: 'id', searchable: true }
1425             };
1426             _.each(data, function(field_def, field_name) {
1427                 if (field_def.selectable !== false && field_name !== 'id') {
1428                     fields[field_name] = field_def;
1429                 }
1430             });
1431             return fields;
1432         });
1433     },
1434     start: function () {
1435         var self = this;
1436         this.$menu = this.$('.filters-menu');
1437         this.$add_filter = this.$('.oe-add-filter');
1438         this.$apply_filter = this.$('.oe-apply-filter');
1439         this.$add_filter_menu = this.$('.oe-add-filter-menu');
1440         _.each(this.filters, function (group) {
1441             if (group.is_visible()) {
1442                 group.insertBefore(self.$add_filter);
1443                 $('<li class="divider">').insertBefore(self.$add_filter);
1444             }
1445         });
1446         this.append_proposition().then(function (prop) {
1447             prop.$el.hide();
1448         });
1449     },
1450     update_max_height: function () {
1451         var max_height = $(window).height() - this.$menu[0].getBoundingClientRect().top - 10;
1452         this.$menu.css('max-height', max_height);
1453     },
1454     toggle_custom_filter_menu: function (is_open) {
1455         this.$add_filter
1456             .toggleClass('closed-menu', !is_open)
1457             .toggleClass('open-menu', is_open);
1458         this.$add_filter_menu.toggle(is_open);
1459         if (this.$add_filter.hasClass('closed-menu') && (!this.propositions.length)) {
1460             this.append_proposition();
1461         }
1462         this.$('.oe-filter-condition').toggle(is_open);
1463         this.update_max_height();
1464     },
1465     append_proposition: function () {
1466         var self = this;
1467         return this.fields_def.then(function (fields) {
1468             var prop = new instance.web.search.ExtendedSearchProposition(self, fields);
1469             self.propositions.push(prop);
1470             prop.insertBefore(self.$add_filter_menu);
1471             self.$apply_filter.prop('disabled', false);
1472             self.update_max_height();
1473             return prop;
1474         });
1475     },
1476     remove_proposition: function (prop) {
1477         this.propositions = _.without(this.propositions, prop);
1478         if (!this.propositions.length) {
1479             this.$apply_filter.prop('disabled', true);
1480         }
1481         prop.destroy();
1482     },
1483     commit_search: function () {
1484         var filters = _.invoke(this.propositions, 'get_filter'),
1485             filters_widgets = _.map(filters, function (filter) {
1486                 return new my.Filter(filter, this);
1487             }),
1488             filter_group = new my.FilterGroup(filters_widgets, this.searchview),
1489             facets = filters_widgets.map(function (filter) {
1490                 return filter_group.make_facet([filter_group.make_value(filter)]);
1491             });
1492         filter_group.insertBefore(this.$add_filter);
1493         $('<li class="divider">').insertBefore(this.$add_filter);
1494         this.searchview.query.add(facets, {silent: true});
1495         this.searchview.query.trigger('reset');
1496
1497         _.invoke(this.propositions, 'destroy');
1498         this.propositions = [];
1499         this.append_proposition();
1500         this.toggle_custom_filter_menu(false);
1501     },
1502 });
1503
1504 instance.web.search.GroupByMenu = instance.web.Widget.extend({
1505     template: 'SearchView.GroupByMenu',
1506     events: {
1507         'click li': function (event) {
1508             event.stopImmediatePropagation();
1509         },
1510         'hidden.bs.dropdown': function () {
1511             this.toggle_add_menu(false);
1512         },
1513         'click .add-custom-group a': function () {
1514             this.toggle_add_menu();
1515         },
1516     },
1517     init: function (parent, groups, fields_def) {
1518         this._super(parent);
1519         this.groups = groups || [];
1520         this.groupable_fields = {};
1521         this.searchview = parent;
1522         this.fields_def = fields_def.then(this.proxy('get_groupable_fields'));
1523     },
1524     start: function () {
1525         var self = this;
1526         this.$menu = this.$('.group-by-menu');
1527         var divider = this.$menu.find('.divider');
1528         _.invoke(this.groups, 'insertBefore', divider);
1529         if (this.groups.length) {
1530             divider.show();
1531         }
1532         this.$add_group = this.$menu.find('.add-custom-group');
1533         this.fields_def.then(function () {
1534             self.$menu.append(QWeb.render('GroupByMenuSelector', self));
1535             self.$add_group_menu = self.$('.oe-add-group');
1536             self.$group_selector = self.$('.oe-group-selector');
1537             self.$('.oe-select-group').click(function (event) {
1538                 self.toggle_add_menu(false);
1539                 var field = self.$group_selector.find(':selected').data('name');
1540                 self.add_groupby_to_menu(field);
1541             });            
1542         });
1543     },
1544     get_groupable_fields: function (fields) {
1545         var self = this,
1546             groupable_types = ['many2one', 'char', 'boolean', 'selection', 'date', 'datetime'];
1547
1548         _.each(fields, function (field, name) {
1549             if (field.store && _.contains(groupable_types, field.type)) {
1550                 self.groupable_fields[name] = field;
1551             }
1552         });
1553     },
1554     toggle_add_menu: function (is_open) {
1555         this.$add_group
1556             .toggleClass('closed-menu', !is_open)
1557             .toggleClass('open-menu', is_open);
1558         this.$add_group_menu.toggle(is_open);
1559         if (this.$add_group.hasClass('open-menu')) {
1560             this.$group_selector.focus();
1561         }
1562     },
1563     add_groupby_to_menu: function (field_name) {
1564         var filter = new my.Filter({attrs:{
1565             context:"{'group_by':'" + field_name + "''}",
1566             name: this.groupable_fields[field_name].string,
1567         }}, this.searchview);
1568         var group = new my.FilterGroup([filter], this.searchview),
1569             divider = this.$('.divider').show();
1570         group.insertBefore(divider);
1571         group.toggle(filter);
1572     },
1573 });
1574
1575 instance.web.search.FavoriteMenu = instance.web.Widget.extend({
1576     template: 'SearchView.FavoriteMenu',
1577     events: {
1578         'click li': function (event) {
1579             event.stopImmediatePropagation();
1580         },
1581         'click .oe-save-search a': function () {
1582             this.toggle_save_menu();
1583         },
1584         'click .oe-save-name button': 'save_favorite',
1585         'hidden.bs.dropdown': function () {
1586             this.close_menus();
1587         },
1588     },
1589     init: function (parent, query, target_model, action_id) {
1590         this._super.apply(this,arguments);
1591         this.searchview = parent;
1592         this.query = query;
1593         this.target_model = target_model;
1594         this.model = new instance.web.Model('ir.filters');
1595         this.filters = {};
1596         this.$filters = {};
1597         this.action_id = action_id;
1598     },
1599     start: function () {
1600         var self = this;
1601         this.$save_search = this.$('.oe-save-search');
1602         this.$save_name = this.$('.oe-save-name');
1603         this.$inputs = this.$save_name.find('input');
1604         this.$divider = this.$('.divider');
1605         this.$inputs.eq(0).val(this.searchview.getParent().title);
1606         var $shared_filter = this.$inputs.eq(1),
1607             $default_filter = this.$inputs.eq(2);
1608         $shared_filter.click(function () {$default_filter.prop('checked', false)});
1609         $default_filter.click(function () {$shared_filter.prop('checked', false)});
1610
1611         this.query
1612             .on('remove', function (facet) {
1613                 if (facet.get('is_custom_filter')) {
1614                     self.clear_selection();
1615                 }
1616             })
1617             .on('reset', this.proxy('clear_selection'));
1618         if (!this.action_id) return $.when();
1619         return this.model.call('get_filters', [this.target_model, this.action_id])
1620             .done(this.proxy('prepare_dropdown_menu'));
1621     },
1622     prepare_dropdown_menu: function (filters) {
1623         filters.map(this.append_filter.bind(this));
1624     },
1625     toggle_save_menu: function (is_open) {
1626         this.$save_search
1627             .toggleClass('closed-menu', !is_open)
1628             .toggleClass('open-menu', is_open);
1629         this.$save_name.toggle(is_open);
1630         if (this.$save_search.hasClass('open-menu')) {
1631             this.$save_name.find('input').first().focus();
1632         }
1633     },
1634     close_menus: function () {
1635         this.toggle_save_menu(false);
1636     },
1637     save_favorite: function () {
1638         var self = this,
1639             filter_name = this.$inputs[0].value,
1640             default_filter = this.$inputs[1].checked,
1641             shared_filter = this.$inputs[2].checked;
1642         if (!filter_name.length){
1643             this.do_warn(_t("Error"), _t("Filter name is required."));
1644             this.$inputs.first().focus();
1645             return;
1646         }
1647         var search = this.searchview.build_search_data(),
1648             results = instance.web.pyeval.sync_eval_domains_and_contexts({
1649                 domains: search.domains,
1650                 contexts: search.contexts,
1651                 group_by_seq: search.groupbys || [],
1652             });
1653         if (!_.isEmpty(results.group_by)) {
1654             results.context.group_by = results.group_by;
1655         }
1656         // Don't save user_context keys in the custom filter, otherwise end
1657         // up with e.g. wrong uid or lang stored *and used in subsequent
1658         // reqs*
1659         var ctx = results.context;
1660         _(_.keys(instance.session.user_context)).each(function (key) {
1661             delete ctx[key];
1662         });
1663         var filter = {
1664             name: filter_name,
1665             user_id: shared_filter ? false : instance.session.uid,
1666             model_id: this.searchview.dataset.model,
1667             context: results.context,
1668             domain: results.domain,
1669             is_default: default_filter,
1670             action_id: this.action_id,
1671         };
1672         return this.model.call('create_or_replace', [filter]).done(function (id) {
1673             filter.id = id;
1674             self.toggle_save_menu(false);
1675             self.$save_name.find('input').val('').prop('checked', false);
1676             self.append_filter(filter);
1677             self.toggle_filter(filter, true);
1678         });
1679     },
1680     get_default_filter: function () {
1681         var personal_filter = _.find(this.filters, function (filter) {
1682             return filter.user_id && filter.is_default;
1683         });
1684         if (personal_filter) {
1685             return personal_filter;
1686         }
1687         return _.find(this.filters, function (filter) {
1688             return !filter.user_id && filter.is_default;
1689         });
1690     },
1691     /**
1692      * Generates a mapping key (in the filters and $filter mappings) for the
1693      * filter descriptor object provided (as returned by ``get_filters``).
1694      *
1695      * The mapping key is guaranteed to be unique for a given (user_id, name)
1696      * pair.
1697      *
1698      * @param {Object} filter
1699      * @param {String} filter.name
1700      * @param {Number|Pair<Number, String>} [filter.user_id]
1701      * @return {String} mapping key corresponding to the filter
1702      */
1703     key_for: function (filter) {
1704         var user_id = filter.user_id,
1705             action_id = filter.action_id,
1706             uid = (user_id instanceof Array) ? user_id[0] : user_id,
1707             act_id = (action_id instanceof Array) ? action_id[0] : action_id;
1708         return _.str.sprintf('(%s)(%s)%s', uid, act_id, filter.name);
1709     },
1710     /**
1711      * Generates a :js:class:`~instance.web.search.Facet` descriptor from a
1712      * filter descriptor
1713      *
1714      * @param {Object} filter
1715      * @param {String} filter.name
1716      * @param {Object} [filter.context]
1717      * @param {Array} [filter.domain]
1718      * @return {Object}
1719      */
1720     facet_for: function (filter) {
1721         return {
1722             category: _t("Custom Filter"),
1723             icon: 'fa-star',
1724             field: {
1725                 get_context: function () { return filter.context; },
1726                 get_groupby: function () { return [filter.context]; },
1727                 get_domain: function () { return filter.domain; }
1728             },
1729             _id: filter.id,
1730             is_custom_filter: true,
1731             values: [{label: filter.name, value: null}]
1732         };
1733     },
1734     clear_selection: function () {
1735         this.$('li.selected').removeClass('selected');
1736     },
1737     append_filter: function (filter) {
1738         var self = this,
1739             key = this.key_for(filter),
1740             $filter;
1741
1742         this.$divider.show();
1743         if (key in this.$filters) {
1744             $filter = this.$filters[key];
1745         } else {
1746             this.filters[key] = filter;
1747             $filter = $('<li></li>')
1748                 .insertBefore(this.$divider)
1749                 .toggleClass('oe_searchview_custom_default', filter.is_default)
1750                 .append($('<a>').text(filter.name));
1751
1752             this.$filters[key] = $filter;
1753             this.$filters[key].addClass(filter.user_id ? 'oe_searchview_custom_private'
1754                                          : 'oe_searchview_custom_public')
1755             $('<span>')
1756                 .addClass('fa fa-trash-o remove-filter')
1757                 .click(function (event) {
1758                     event.stopImmediatePropagation(); 
1759                     self.remove_filter(filter, $filter, key);
1760                 })
1761                 .appendTo($filter);
1762         }
1763         this.$filters[key].unbind('click').click(function () {
1764             self.toggle_filter(filter);
1765         });
1766     },
1767     toggle_filter: function (filter, preventSearch) {
1768         var current = this.query.find(function (facet) {
1769             return facet.get('_id') === filter.id;
1770         });
1771         if (current) {
1772             this.query.remove(current);
1773             this.$filters[this.key_for(filter)].removeClass('selected');
1774             return;
1775         }
1776         this.query.reset([this.facet_for(filter)], {
1777             preventSearch: preventSearch || false});
1778         this.$filters[this.key_for(filter)].addClass('selected');
1779     },
1780     remove_filter: function (filter, $filter, key) {
1781         var self = this;
1782         var global_warning = _t("This filter is global and will be removed for everybody if you continue."),
1783             warning = _t("Are you sure that you want to remove this filter?");
1784         if (!confirm(filter.user_id ? warning : global_warning)) {
1785             return;
1786         }
1787         this.model.call('unlink', [filter.id]).done(function () {
1788             $filter.remove();
1789             delete self.$filters[key];
1790             delete self.filters[key];
1791             if (_.isEmpty(self.filters)) {
1792                 self.$divider.hide();
1793             }
1794         });        
1795     },
1796 });
1797
1798 instance.web.search.ExtendedSearchProposition = instance.web.Widget.extend(/** @lends instance.web.search.ExtendedSearchProposition# */{
1799     template: 'SearchView.extended_search.proposition',
1800     events: {
1801         'change .searchview_extended_prop_field': 'changed',
1802         'change .searchview_extended_prop_op': 'operator_changed',
1803         'click .searchview_extended_delete_prop': function (e) {
1804             e.stopPropagation();
1805             this.getParent().remove_proposition(this);
1806         },
1807     },
1808     /**
1809      * @constructs instance.web.search.ExtendedSearchProposition
1810      * @extends instance.web.Widget
1811      *
1812      * @param parent
1813      * @param fields
1814      */
1815     init: function (parent, fields) {
1816         this._super(parent);
1817         this.fields = _(fields).chain()
1818             .map(function(val, key) { return _.extend({}, val, {'name': key}); })
1819             .filter(function (field) { return !field.deprecated && field.searchable; })
1820             .sortBy(function(field) {return field.string;})
1821             .value();
1822         this.attrs = {_: _, fields: this.fields, selected: null};
1823         this.value = null;
1824     },
1825     start: function () {
1826         return this._super().done(this.proxy('changed'));
1827     },
1828     changed: function() {
1829         var nval = this.$(".searchview_extended_prop_field").val();
1830         if(this.attrs.selected === null || this.attrs.selected === undefined || nval != this.attrs.selected.name) {
1831             this.select_field(_.detect(this.fields, function(x) {return x.name == nval;}));
1832         }
1833     },
1834     operator_changed: function (e) {
1835         var $value = this.$('.searchview_extended_prop_value');
1836         switch ($(e.target).val()) {
1837         case '∃':
1838         case '∄':
1839             $value.hide();
1840             break;
1841         default:
1842             $value.show();
1843         }
1844     },
1845     /**
1846      * Selects the provided field object
1847      *
1848      * @param field a field descriptor object (as returned by fields_get, augmented by the field name)
1849      */
1850     select_field: function(field) {
1851         var self = this;
1852         if(this.attrs.selected !== null && this.attrs.selected !== undefined) {
1853             this.value.destroy();
1854             this.value = null;
1855             this.$('.searchview_extended_prop_op').html('');
1856         }
1857         this.attrs.selected = field;
1858         if(field === null || field === undefined) {
1859             return;
1860         }
1861
1862         var type = field.type;
1863         var Field = instance.web.search.custom_filters.get_object(type);
1864         if(!Field) {
1865             Field = instance.web.search.custom_filters.get_object("char");
1866         }
1867         this.value = new Field(this, field);
1868         _.each(this.value.operators, function(operator) {
1869             $('<option>', {value: operator.value})
1870                 .text(String(operator.text))
1871                 .appendTo(self.$('.searchview_extended_prop_op'));
1872         });
1873         var $value_loc = this.$('.searchview_extended_prop_value').show().empty();
1874         this.value.appendTo($value_loc);
1875
1876     },
1877     get_filter: function () {
1878         if (this.attrs.selected === null || this.attrs.selected === undefined)
1879             return null;
1880         var field = this.attrs.selected,
1881             op_select = this.$('.searchview_extended_prop_op')[0],
1882             operator = op_select.options[op_select.selectedIndex];
1883
1884         return {
1885             attrs: {
1886                 domain: [this.value.get_domain(field, operator)],
1887                 string: this.value.get_label(field, operator),
1888             },
1889             children: [],
1890             tag: 'filter',
1891         };
1892     },
1893 });
1894
1895 instance.web.search.ExtendedSearchProposition.Field = instance.web.Widget.extend({
1896     init: function (parent, field) {
1897         this._super(parent);
1898         this.field = field;
1899     },
1900     get_label: function (field, operator) {
1901         var format;
1902         switch (operator.value) {
1903         case '∃': case '∄': format = _t('%(field)s %(operator)s'); break;
1904         default: format = _t('%(field)s %(operator)s "%(value)s"'); break;
1905         }
1906         return this.format_label(format, field, operator);
1907     },
1908     format_label: function (format, field, operator) {
1909         return _.str.sprintf(format, {
1910             field: field.string,
1911             // According to spec, HTMLOptionElement#label should return
1912             // HTMLOptionElement#text when not defined/empty, but it does
1913             // not in older Webkit (between Safari 5.1.5 and Chrome 17) and
1914             // Gecko (pre Firefox 7) browsers, so we need a manual fallback
1915             // for those
1916             operator: operator.label || operator.text,
1917             value: this
1918         });
1919     },
1920     get_domain: function (field, operator) {
1921         switch (operator.value) {
1922         case '∃': return this.make_domain(field.name, '!=', false);
1923         case '∄': return this.make_domain(field.name, '=', false);
1924         default: return this.make_domain(
1925             field.name, operator.value, this.get_value());
1926         }
1927     },
1928     make_domain: function (field, operator, value) {
1929         return [field, operator, value];
1930     },
1931     /**
1932      * Returns a human-readable version of the value, in case the "logical"
1933      * and the "semantic" values of a field differ (as for selection fields,
1934      * for instance).
1935      *
1936      * The default implementation simply returns the value itself.
1937      *
1938      * @return {String} human-readable version of the value
1939      */
1940     toString: function () {
1941         return this.get_value();
1942     }
1943 });
1944 instance.web.search.ExtendedSearchProposition.Char = instance.web.search.ExtendedSearchProposition.Field.extend({
1945     template: 'SearchView.extended_search.proposition.char',
1946     operators: [
1947         {value: "ilike", text: _lt("contains")},
1948         {value: "not ilike", text: _lt("doesn't contain")},
1949         {value: "=", text: _lt("is equal to")},
1950         {value: "!=", text: _lt("is not equal to")},
1951         {value: "∃", text: _lt("is set")},
1952         {value: "∄", text: _lt("is not set")}
1953     ],
1954     get_value: function() {
1955         return this.$el.val();
1956     }
1957 });
1958 instance.web.search.ExtendedSearchProposition.DateTime = instance.web.search.ExtendedSearchProposition.Field.extend({
1959     template: 'SearchView.extended_search.proposition.empty',
1960     operators: [
1961         {value: "=", text: _lt("is equal to")},
1962         {value: "!=", text: _lt("is not equal to")},
1963         {value: ">", text: _lt("greater than")},
1964         {value: "<", text: _lt("less than")},
1965         {value: ">=", text: _lt("greater or equal than")},
1966         {value: "<=", text: _lt("less or equal than")},
1967         {value: "∃", text: _lt("is set")},
1968         {value: "∄", text: _lt("is not set")}
1969     ],
1970     /**
1971      * Date widgets live in view_form which is not yet loaded when this is
1972      * initialized -_-
1973      */
1974     widget: function () { return instance.web.DateTimeWidget; },
1975     get_value: function() {
1976         return this.datewidget.get_value();
1977     },
1978     toString: function () {
1979         return instance.web.format_value(this.get_value(), { type:"datetime" });
1980     },
1981     start: function() {
1982         var ready = this._super();
1983         this.datewidget = new (this.widget())(this);
1984         this.datewidget.appendTo(this.$el);
1985         return ready;
1986     }
1987 });
1988 instance.web.search.ExtendedSearchProposition.Date = instance.web.search.ExtendedSearchProposition.DateTime.extend({
1989     widget: function () { return instance.web.DateWidget; },
1990     toString: function () {
1991         return instance.web.format_value(this.get_value(), { type:"date" });
1992     }
1993 });
1994 instance.web.search.ExtendedSearchProposition.Integer = instance.web.search.ExtendedSearchProposition.Field.extend({
1995     template: 'SearchView.extended_search.proposition.integer',
1996     operators: [
1997         {value: "=", text: _lt("is equal to")},
1998         {value: "!=", text: _lt("is not equal to")},
1999         {value: ">", text: _lt("greater than")},
2000         {value: "<", text: _lt("less than")},
2001         {value: ">=", text: _lt("greater or equal than")},
2002         {value: "<=", text: _lt("less or equal than")},
2003         {value: "∃", text: _lt("is set")},
2004         {value: "∄", text: _lt("is not set")}
2005     ],
2006     toString: function () {
2007         return this.$el.val();
2008     },
2009     get_value: function() {
2010         try {
2011             var val =this.$el.val();
2012             return instance.web.parse_value(val === "" ? 0 : val, {'widget': 'integer'});
2013         } catch (e) {
2014             return "";
2015         }
2016     }
2017 });
2018 instance.web.search.ExtendedSearchProposition.Id = instance.web.search.ExtendedSearchProposition.Integer.extend({
2019     operators: [{value: "=", text: _lt("is")}]
2020 });
2021 instance.web.search.ExtendedSearchProposition.Float = instance.web.search.ExtendedSearchProposition.Field.extend({
2022     template: 'SearchView.extended_search.proposition.float',
2023     operators: [
2024         {value: "=", text: _lt("is equal to")},
2025         {value: "!=", text: _lt("is not equal to")},
2026         {value: ">", text: _lt("greater than")},
2027         {value: "<", text: _lt("less than")},
2028         {value: ">=", text: _lt("greater or equal than")},
2029         {value: "<=", text: _lt("less or equal than")},
2030         {value: "∃", text: _lt("is set")},
2031         {value: "∄", text: _lt("is not set")}
2032     ],
2033     toString: function () {
2034         return this.$el.val();
2035     },
2036     get_value: function() {
2037         try {
2038             var val =this.$el.val();
2039             return instance.web.parse_value(val === "" ? 0.0 : val, {'widget': 'float'});
2040         } catch (e) {
2041             return "";
2042         }
2043     }
2044 });
2045 instance.web.search.ExtendedSearchProposition.Selection = instance.web.search.ExtendedSearchProposition.Field.extend({
2046     template: 'SearchView.extended_search.proposition.selection',
2047     operators: [
2048         {value: "=", text: _lt("is")},
2049         {value: "!=", text: _lt("is not")},
2050         {value: "∃", text: _lt("is set")},
2051         {value: "∄", text: _lt("is not set")}
2052     ],
2053     toString: function () {
2054         var select = this.$el[0];
2055         var option = select.options[select.selectedIndex];
2056         return option.label || option.text;
2057     },
2058     get_value: function() {
2059         return this.$el.val();
2060     }
2061 });
2062 instance.web.search.ExtendedSearchProposition.Boolean = instance.web.search.ExtendedSearchProposition.Field.extend({
2063     template: 'SearchView.extended_search.proposition.empty',
2064     operators: [
2065         {value: "=", text: _lt("is true")},
2066         {value: "!=", text: _lt("is false")}
2067     ],
2068     get_label: function (field, operator) {
2069         return this.format_label(
2070             _t('%(field)s %(operator)s'), field, operator);
2071     },
2072     get_value: function() {
2073         return true;
2074     }
2075 });
2076
2077 instance.web.search.custom_filters = new instance.web.Registry({
2078     'char': 'instance.web.search.ExtendedSearchProposition.Char',
2079     'text': 'instance.web.search.ExtendedSearchProposition.Char',
2080     'one2many': 'instance.web.search.ExtendedSearchProposition.Char',
2081     'many2one': 'instance.web.search.ExtendedSearchProposition.Char',
2082     'many2many': 'instance.web.search.ExtendedSearchProposition.Char',
2083
2084     'datetime': 'instance.web.search.ExtendedSearchProposition.DateTime',
2085     'date': 'instance.web.search.ExtendedSearchProposition.Date',
2086     'integer': 'instance.web.search.ExtendedSearchProposition.Integer',
2087     'float': 'instance.web.search.ExtendedSearchProposition.Float',
2088     'boolean': 'instance.web.search.ExtendedSearchProposition.Boolean',
2089     'selection': 'instance.web.search.ExtendedSearchProposition.Selection',
2090
2091     'id': 'instance.web.search.ExtendedSearchProposition.Id'
2092 });
2093
2094 instance.web.search.AutoComplete = instance.web.Widget.extend({
2095     template: "SearchView.autocomplete",
2096
2097     // Parameters for autocomplete constructor:
2098     //
2099     // parent: this is used to detect keyboard events
2100     //
2101     // options.source: function ({term:query}, callback).  This function will be called to
2102     //      obtain the search results corresponding to the query string.  It is assumed that
2103     //      options.source will call callback with the results.
2104     // options.delay: delay in millisecond before calling source.  Useful if you don't want
2105     //      to make too many rpc calls
2106     // options.select: function (ev, {item: {facet:facet}}).  Autocomplete widget will call
2107     //      that function when a selection is made by the user
2108     // options.get_search_string: function ().  This function will be called by autocomplete
2109     //      to obtain the current search string.
2110     init: function (parent, options) {
2111         this._super(parent);
2112         this.$input = parent.$el;
2113         this.source = options.source;
2114         this.delay = options.delay;
2115         this.select = options.select,
2116         this.get_search_string = options.get_search_string;
2117
2118         this.current_result = null;
2119
2120         this.searching = true;
2121         this.search_string = null;
2122         this.current_search = null;
2123     },
2124     start: function () {
2125         var self = this;
2126         this.$input.on('keyup', function (ev) {
2127             if (ev.which === $.ui.keyCode.RIGHT) {
2128                 self.searching = true;
2129                 ev.preventDefault();
2130                 return;
2131             }
2132             // ENTER is caugth at KeyUp rather than KeyDown to avoid firing
2133             // before all regular keystrokes have been processed
2134             if (ev.which === $.ui.keyCode.ENTER) {
2135                 if (self.current_result && self.get_search_string().length) {
2136                     self.select_item(ev);
2137                 }
2138                 return;
2139             }
2140             if (!self.searching) {
2141                 self.searching = true;
2142                 return;
2143             }
2144             self.search_string = self.get_search_string();
2145             if (self.search_string.length) {
2146                 var search_string = self.search_string;
2147                 setTimeout(function () { self.initiate_search(search_string);}, self.delay);
2148             } else {
2149                 self.close();
2150             }
2151         });
2152         this.$input.on('keydown', function (ev) {
2153             switch (ev.which) {
2154                 // TAB and direction keys are handled at KeyDown because KeyUp
2155                 // is not guaranteed to fire.
2156                 // See e.g. https://github.com/aef-/jquery.masterblaster/issues/13
2157                 case $.ui.keyCode.TAB:
2158                     if (self.current_result && self.get_search_string().length) {
2159                         self.select_item(ev);
2160                     }
2161                     break;
2162                 case $.ui.keyCode.DOWN:
2163                     self.move('down');
2164                     self.searching = false;
2165                     ev.preventDefault();
2166                     break;
2167                 case $.ui.keyCode.UP:
2168                     self.move('up');
2169                     self.searching = false;
2170                     ev.preventDefault();
2171                     break;
2172                 case $.ui.keyCode.RIGHT:
2173                     self.searching = false;
2174                     var current = self.current_result
2175                     if (current && current.expand && !current.expanded) {
2176                         self.expand();
2177                         self.searching = true;
2178                     }
2179                     ev.preventDefault();
2180                     break;
2181                 case $.ui.keyCode.ESCAPE:
2182                     self.close();
2183                     self.searching = false;
2184                     break;
2185             }
2186         });
2187     },
2188     initiate_search: function (query) {
2189         if (query === this.search_string && query !== this.current_search) {
2190             this.search(query);
2191         }
2192     },
2193     search: function (query) {
2194         var self = this;
2195         this.current_search = query;
2196         this.source({term:query}, function (results) {
2197             if (results.length) {
2198                 self.render_search_results(results);
2199                 self.focus_element(self.$('li:first-child'));
2200             } else {
2201                 self.close();
2202             }
2203         });
2204     },
2205     render_search_results: function (results) {
2206         var self = this;
2207         var $list = this.$('ul');
2208         $list.empty();
2209         var render_separator = false;
2210         results.forEach(function (result) {
2211             if (result.is_separator) {
2212                 if (render_separator)
2213                     $list.append($('<li>').addClass('oe-separator'));
2214                 render_separator = false;
2215             } else {
2216                 var $item = self.make_list_item(result).appendTo($list);
2217                 result.$el = $item;
2218                 render_separator = true;
2219             }
2220         });
2221         this.show();
2222     },
2223     make_list_item: function (result) {
2224         var self = this;
2225         var $li = $('<li>')
2226             .hover(function (ev) {self.focus_element($li);})
2227             .mousedown(function (ev) {
2228                 if (ev.button === 0) { // left button
2229                     self.select(ev, {item: {facet: result.facet}});
2230                     self.close();
2231                 } else {
2232                     ev.preventDefault();
2233                 }
2234             })
2235             .data('result', result);
2236         if (result.expand) {
2237             var $expand = $('<span class="oe-expand">').text('▶').appendTo($li);
2238             $expand.mousedown(function (ev) {
2239                 ev.preventDefault();
2240                 ev.stopPropagation();
2241                 if (result.expanded)
2242                     self.fold();
2243                 else
2244                     self.expand();
2245             });
2246             result.expanded = false;
2247         }
2248         if (result.indent) $li.addClass('oe-indent');
2249         $li.append($('<span>').html(result.label));
2250         return $li;
2251     },
2252     expand: function () {
2253         var self = this;
2254         this.current_result.expand(this.get_search_string()).then(function (results) {
2255             (results || [{label: '(no result)'}]).reverse().forEach(function (result) {
2256                 result.indent = true;
2257                 var $li = self.make_list_item(result);
2258                 self.current_result.$el.after($li);
2259             });
2260             self.current_result.expanded = true;
2261             self.current_result.$el.find('span.oe-expand').html('▼');
2262         });
2263     },
2264     fold: function () {
2265         var $next = this.current_result.$el.next();
2266         while ($next.hasClass('oe-indent')) {
2267             $next.remove();
2268             $next = this.current_result.$el.next();
2269         }
2270         this.current_result.expanded = false;
2271         this.current_result.$el.find('span.oe-expand').html('▶');
2272     },
2273     focus_element: function ($li) {
2274         this.$('li').removeClass('oe-selection-focus');
2275         $li.addClass('oe-selection-focus');
2276         this.current_result = $li.data('result');
2277     },
2278     select_item: function (ev) {
2279         if (this.current_result.facet) {
2280             this.select(ev, {item: {facet: this.current_result.facet}});
2281             this.close();
2282         }
2283     },
2284     show: function () {
2285         this.$el.show();
2286     },
2287     close: function () {
2288         this.current_search = null;
2289         this.search_string = null;
2290         this.searching = true;
2291         this.$el.hide();
2292     },
2293     move: function (direction) {
2294         var $next;
2295         if (direction === 'down') {
2296             $next = this.$('li.oe-selection-focus').nextAll(':not(.oe-separator)').first();
2297             if (!$next.length) $next = this.$('li:first-child');
2298         } else {
2299             $next = this.$('li.oe-selection-focus').prevAll(':not(.oe-separator)').first();
2300             if (!$next.length) $next = this.$('li:not(.oe-separator)').last();
2301         }
2302         this.focus_element($next);
2303     },
2304     is_expandable: function () {
2305         return !!this.$('.oe-selection-focus .oe-expand').length;
2306     },
2307 });
2308
2309 })();
2310
2311 // vim:et fdc=0 fdl=0 foldnestmax=3 fdm=syntax: