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