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