[MERGE] from trunk
[odoo/odoo.git] / addons / web / static / src / js / search.js
index 8e89595..8e4d4e3 100644 (file)
@@ -2,9 +2,76 @@ openerp.web.search = function(openerp) {
 var QWeb = openerp.web.qweb,
       _t =  openerp.web._t,
      _lt = openerp.web._lt;
+_.mixin({
+    sum: function (obj) { return _.reduce(obj, function (a, b) { return a + b; }, 0); }
+});
+
+// Have SearchBox optionally use callback function to produce inputs and facets
+// (views) set on callbacks.make_facet and callbacks.make_input keys when
+// initializing VisualSearch
+var SearchBox_renderFacet = function (facet, position) {
+    var view = new (this.app.options.callbacks['make_facet'] || VS.ui.SearchFacet)({
+      app   : this.app,
+      model : facet,
+      order : position
+    });
+
+    // Input first, facet second.
+    this.renderSearchInput();
+    this.facetViews.push(view);
+    this.$('.VS-search-inner').children().eq(position*2).after(view.render().el);
+
+    view.calculateSize();
+    _.defer(_.bind(view.calculateSize, view));
 
-openerp.web.SearchView = openerp.web.OldWidget.extend(/** @lends openerp.web.SearchView# */{
-    template: "EmptyComponent",
+    return view;
+  }; // warning: will not match
+// Ensure we're replacing the function we think
+if (SearchBox_renderFacet.toString() !== VS.ui.SearchBox.prototype.renderFacet.toString().replace(/(VS\.ui\.SearchFacet)/, "(this.app.options.callbacks['make_facet'] || $1)")) {
+    throw new Error(
+        "Trying to replace wrong version of VS.ui.SearchBox#renderFacet. "
+        + "Please fix replacement.");
+}
+var SearchBox_renderSearchInput = function () {
+    var input = new (this.app.options.callbacks['make_input'] || VS.ui.SearchInput)({position: this.inputViews.length, app: this.app});
+    this.$('.VS-search-inner').append(input.render().el);
+    this.inputViews.push(input);
+  };
+// Ensure we're replacing the function we think
+if (SearchBox_renderSearchInput.toString() !== VS.ui.SearchBox.prototype.renderSearchInput.toString().replace(/(VS\.ui\.SearchInput)/, "(this.app.options.callbacks['make_input'] || $1)")) {
+    throw new Error(
+        "Trying to replace wrong version of VS.ui.SearchBox#renderSearchInput. "
+        + "Please fix replacement.");
+}
+var SearchBox_searchEvent = function (e) {
+    var query = null;
+    this.renderFacets();
+    this.focusSearch(e);
+    this.app.options.callbacks.search(query, this.app.searchQuery);
+  };
+if (SearchBox_searchEvent.toString() !== VS.ui.SearchBox.prototype.searchEvent.toString().replace(
+        /this\.value\(\);\n[ ]{4}this\.focusSearch\(e\);\n[ ]{4}this\.value\(query\)/,
+        'null;\n    this.renderFacets();\n    this.focusSearch(e)')) {
+    throw new Error(
+        "Trying to replace wrong version of VS.ui.SearchBox#searchEvent. "
+        + "Please fix replacement.");
+}
+_.extend(VS.ui.SearchBox.prototype, {
+    renderFacet: SearchBox_renderFacet,
+    renderSearchInput: SearchBox_renderSearchInput,
+    searchEvent: SearchBox_searchEvent
+});
+_.extend(VS.model.SearchFacet.prototype, {
+    value: function () {
+        if (this.has('json')) {
+            return this.get('json');
+        }
+        return this.get('value');
+    }
+});
+
+openerp.web.SearchView = openerp.web.Widget.extend(/** @lends openerp.web.SearchView# */{
+    template: "SearchView",
     /**
      * @constructs openerp.web.SearchView
      * @extends openerp.web.OldWidget
@@ -25,9 +92,7 @@ openerp.web.SearchView = openerp.web.OldWidget.extend(/** @lends openerp.web.Sea
         this.has_defaults = !_.isEmpty(this.defaults);
 
         this.inputs = [];
-        this.enabled_filters = [];
-
-        this.has_focus = false;
+        this.controls = {};
 
         this.hidden = !!hidden;
         this.headless = this.hidden && !this.has_defaults;
@@ -37,20 +102,63 @@ openerp.web.SearchView = openerp.web.OldWidget.extend(/** @lends openerp.web.Sea
         this.ready = $.Deferred();
     },
     start: function() {
-        this._super();
+        var self = this;
+        var p = this._super();
+
+        this.setup_global_completion();
+        this.vs = VS.init({
+            container: this.$element,
+            query: '',
+            callbacks: {
+                make_facet: this.proxy('make_visualsearch_facet'),
+                make_input: this.proxy('make_visualsearch_input'),
+                search: function (query, searchCollection) {
+                    self.do_search();
+                },
+                facetMatches: function (callback) {
+                },
+                valueMatches : function(facet, searchTerm, callback) {
+                }
+            }
+        });
+
+        var search = function () { self.vs.searchBox.searchEvent({}); };
+        // searchQuery operations
+        this.vs.searchQuery
+            .off('add').on('add', search)
+            .off('change').on('change', search)
+            .off('reset').on('reset', search)
+            .off('remove').on('remove', function (record, collection, options) {
+                if (options['trigger_search']) {
+                    search();
+                }
+            });
+
         if (this.hidden) {
             this.$element.hide();
         }
         if (this.headless) {
             this.ready.resolve();
         } else {
-            this.rpc("/web/searchview/load", {
+            var load_view = this.rpc("/web/searchview/load", {
                 model: this.model,
                 view_id: this.view_id,
-                context: this.dataset.get_context()
-            }, this.on_loaded);
+                context: this.dataset.get_context() });
+            // FIXME: local eval of domain and context to get rid of special endpoint
+            var filters = this.rpc('/web/searchview/get_filters', {
+                model: this.model
+            }).then(function (filters) { self.custom_filters = filters; });
+
+            $.when(load_view, filters)
+                .pipe(function (load) { return load[0]; })
+                .then(this.on_loaded);
         }
-        return this.ready.promise();
+
+        this.$element.on('click', '.oe_vs_unfold_drawer', function () {
+            self.$element.toggleClass('oe_searchview_open_drawer');
+        });
+
+        return $.when(p, this.ready);
     },
     show: function () {
         this.$element.show();
@@ -58,61 +166,223 @@ openerp.web.SearchView = openerp.web.OldWidget.extend(/** @lends openerp.web.Sea
     hide: function () {
         this.$element.hide();
     },
+
+    /**
+     * Sets up thingie where all the mess is put?
+     */
+    setup_stuff_drawer: function () {
+        var self = this;
+        $('<div class="oe_vs_unfold_drawer">').appendTo(this.$element.find('.VS-search-box'));
+        var $drawer = $('<div class="oe_searchview_drawer">').appendTo(this.$element);
+
+        var running_count = 0;
+        // get total filters count
+        var is_group = function (i) { return i instanceof openerp.web.search.FilterGroup; };
+        var filters_count = _(this.controls).chain()
+            .flatten()
+            .filter(is_group)
+            .map(function (i) { return i.filters.length; })
+            .sum()
+            .value();
+
+        var col1 = [], col2 = _(this.controls).map(function (inputs, group) {
+            var filters = _(inputs).filter(is_group);
+            return {
+                name: group === 'null' ? _t("Filters") : group,
+                filters: filters,
+                length: _(filters).chain().map(function (i) {
+                    return i.filters.length; }).sum().value()
+            };
+        });
+
+        while (col2.length) {
+            // col1 + group should be smaller than col2 + group
+            if ((running_count + col2[0].length) <= (filters_count - running_count)) {
+                running_count += col2[0].length;
+                col1.push(col2.shift());
+            } else {
+                break;
+            }
+        }
+
+        // Create a Custom Filter FilterGroup for each custom filter read from
+        // the db, add all of this as a group in the smallest column
+        [].push.call(col1.length <= col2.length ? col1 : col2, {
+            name: _t("Custom Filters"),
+            filters: _.map(this.custom_filters, function (filter) {
+                // FIXME: handling of ``disabled`` being set
+                var f = new openerp.web.search.Filter({attrs: {
+                    string: filter.name,
+                    context: filter.context,
+                    domain: filter.domain
+                }}, self);
+                return new openerp.web.search.FilterGroup([f], self);
+            }),
+            length: 3
+        });
+
+        return $.when(
+            this.render_column(col1, $('<div>').appendTo($drawer)),
+            this.render_column(col2, $('<div>').appendTo($drawer)));
+    },
+    render_column: function (column, $el) {
+        return $.when.apply(null, _(column).map(function (group) {
+            $('<h3>').text(group.name).appendTo($el);
+            return $.when.apply(null,
+                _(group.filters).invoke('appendTo', $el));
+        }));
+    },
+    /**
+     * Sets up search view's view-wide auto-completion widget
+     */
+    setup_global_completion: function () {
+        // Prevent keydown from within a facet's input from reaching the
+        // auto-completion widget and opening the completion list
+        this.$element.on('keydown', '.search_facet input', function (e) {
+            e.stopImmediatePropagation();
+        });
+
+        this.$element.autocomplete({
+            source: this.proxy('complete_global_search'),
+            select: this.proxy('select_completion'),
+            focus: function (e) { e.preventDefault(); },
+            html: true,
+            minLength: 0,
+            delay: 0
+        }).data('autocomplete')._renderItem = function (ul, item) {
+            // item of completion list
+            var $item = $( "<li></li>" )
+                .data( "item.autocomplete", item )
+                .appendTo( ul );
+
+            if (item.value !== undefined) {
+                // regular completion item
+                return $item.append(
+                    (item.label)
+                        ? $('<a>').html(item.label)
+                        : $('<a>').text(item.value));
+            }
+            return $item.text(item.category)
+                .css({
+                    borderTop: '1px solid #cccccc',
+                    margin: 0,
+                    padding: 0,
+                    zoom: 1,
+                    'float': 'left',
+                    clear: 'left',
+                    width: '100%'
+                });
+        }
+    },
+    /**
+     * Provide auto-completion result for req.term (an array to `resp`)
+     *
+     * @param {Object} req request to complete
+     * @param {String} req.term searched term to complete
+     * @param {Function} resp response callback
+     */
+    complete_global_search:  function (req, resp) {
+        $.when.apply(null, _(this.inputs).chain()
+            .invoke('complete', req.term)
+            .value()).then(function () {
+                resp(_(_(arguments).compact()).flatten(true));
+        });
+    },
+
+    /**
+     * Action to perform in case of selection: create a facet (model)
+     * and add it to the search collection
+     *
+     * @param {Object} e selection event, preventDefault to avoid setting value on object
+     * @param {Object} ui selection information
+     * @param {Object} ui.item selected completion item
+     */
+    select_completion: function (e, ui) {
+        e.preventDefault();
+        this.vs.searchQuery.add(new VS.model.SearchFacet(_.extend(
+            {app: this.vs}, ui.item)));
+        this.vs.searchBox.searchEvent({});
+    },
+
+    /**
+     * Builds the right SearchFacet view based on the facet object to render
+     * (e.g. readonly facets for filters)
+     *
+     * @param {Object} options
+     * @param {VS.model.SearchFacet} options.model facet object to render
+     */
+    make_visualsearch_facet: function (options) {
+        if (options.model.get('field') instanceof openerp.web.search.FilterGroup) {
+            return new openerp.web.search.FilterGroupFacet(options);
+        }
+        return new VS.ui.SearchFacet(options);
+    },
+    /**
+     * Proxies searches on a SearchInput to the search view's global completion
+     *
+     * Also disables SearchInput.autocomplete#_move so search view's
+     * autocomplete can get the corresponding events, or something.
+     *
+     * @param options
+     */
+    make_visualsearch_input: function (options) {
+        var self = this, input = new VS.ui.SearchInput(options);
+        input.setupAutocomplete = function () {
+            _.extend(this.box.autocomplete({
+                minLength: 1,
+                delay: 0,
+                search: function () {
+                    self.$element.autocomplete('search', input.box.val());
+                    return false;
+                }
+            }).data('autocomplete'), {
+                _move: function () {},
+                close: function () { self.$element.autocomplete('close'); }
+            });
+        };
+        return input;
+    },
+
     /**
      * Builds a list of widget rows (each row is an array of widgets)
      *
      * @param {Array} items a list of nodes to convert to widgets
      * @param {Object} fields a mapping of field names to (ORM) field attributes
-     * @returns Array
+     * @param {String} [group_name] name of the group to put the new controls in
      */
-    make_widgets: function (items, fields) {
-        var rows = [],
-            row = [];
-        rows.push(row);
+    make_widgets: function (items, fields, group_name) {
+        group_name = group_name || null;
+        if (!(group_name in this.controls)) {
+            this.controls[group_name] = [];
+        }
+        var self = this, group = this.controls[group_name];
         var filters = [];
         _.each(items, function (item) {
             if (filters.length && item.tag !== 'filter') {
-                row.push(
-                    new openerp.web.search.FilterGroup(
-                        filters, this));
+                group.push(new openerp.web.search.FilterGroup(filters, this));
                 filters = [];
             }
 
-            if (item.tag === 'newline') {
-                row = [];
-                rows.push(row);
-            } else if (item.tag === 'filter') {
-                if (!this.has_focus) {
-                    item.attrs.default_focus = '1';
-                    this.has_focus = true;
-                }
-                filters.push(
-                    new openerp.web.search.Filter(
-                        item, this));
-            } else if (item.tag === 'separator') {
-                // a separator is a no-op
-            } else {
-                if (item.tag === 'group') {
-                    // TODO: group and field should be fetched from registries, maybe even filters
-                    row.push(
-                        new openerp.web.search.Group(
-                            item, this, fields));
-                } else if (item.tag === 'field') {
-                    if (!this.has_focus) {
-                        item.attrs.default_focus = '1';
-                        this.has_focus = true;
-                    }
-                    row.push(
-                        this.make_field(
-                            item, fields[item['attrs'].name]));
-                }
+            switch (item.tag) {
+            case 'separator': case 'newline':
+                break;
+            case 'filter':
+                filters.push(new openerp.web.search.Filter(item, this));
+                break;
+            case 'group':
+                self.make_widgets(item.children, fields, item.attrs.string);
+                break;
+            case 'field':
+                group.push(this.make_field(item, fields[item['attrs'].name]));
+                // filters
+                self.make_widgets(item.children, fields, group_name);
+                break;
             }
         }, this);
+
         if (filters.length) {
-            row.push(new openerp.web.search.FilterGroup(filters, this));
+            group.push(new openerp.web.search.FilterGroup(filters, this));
         }
-
-        return rows;
     },
     /**
      * Creates a field for the provided field descriptor item (which comes
@@ -141,6 +411,7 @@ openerp.web.SearchView = openerp.web.OldWidget.extend(/** @lends openerp.web.Sea
         }
     },
     on_loaded: function(data) {
+        var self = this;
         this.fields_view = data.fields_view;
         if (data.fields_view.type !== 'search' ||
             data.fields_view.arch.tag !== 'search') {
@@ -148,52 +419,17 @@ openerp.web.SearchView = openerp.web.OldWidget.extend(/** @lends openerp.web.Sea
                     "Got non-search view after asking for a search view: type %s, arch root %s",
                     data.fields_view.type, data.fields_view.arch.tag));
         }
-        var self = this,
-           lines = this.make_widgets(
-                data.fields_view['arch'].children,
-                data.fields_view.fields);
-
-        // for extended search view
-        var ext = new openerp.web.search.ExtendedSearch(this, this.model);
-        lines.push([ext]);
-        this.extended_search = ext;
-
-        var render = QWeb.render("SearchView", {
-            'view': data.fields_view['arch'],
-            'lines': lines,
-            'defaults': this.defaults
-        });
 
-        this.$element.html(render);
+        this.make_widgets(
+            data.fields_view['arch'].children,
+            data.fields_view.fields);
 
-        var f = this.$element.find('form');
-        this.$element.find('form')
-                .submit(this.do_search)
-                .bind('reset', this.do_clear);
-        // start() all the widgets
-        var widget_starts = _(lines).chain().flatten().map(function (widget) {
-            return widget.start();
-        }).value();
-
-        $.when.apply(null, widget_starts).then(function () {
-            self.ready.resolve();
-        });
-
-        this.reload_managed_filters();
-    },
-    reload_managed_filters: function() {
-        var self = this;
-        return this.rpc('/web/searchview/get_filters', {
-            model: this.dataset.model
-        }).then(function(result) {
-            self.managed_filters = result;
-            var filters = self.$element.find(".oe_search-view-filters-management");
-            filters.html(QWeb.render("SearchView.managed-filters", {
-                filters: result,
-                disabled_filter_message: _t('Filter disabled due to invalid syntax')
-            }));
-            filters.change(self.on_filters_management);
-        });
+        // load defaults
+        return $.when(
+                this.setup_stuff_drawer(),
+                $.when.apply(null, _(this.inputs).invoke('facet_for_defaults', this.defaults))
+                    .then(function () { self.vs.searchQuery.reset(_(arguments).compact()); }))
+            .then(function () { self.ready.resolve(); })
     },
     /**
      * Handle event when the user make a selection in the filters management select box.
@@ -342,37 +578,20 @@ openerp.web.SearchView = openerp.web.OldWidget.extend(/** @lends openerp.web.Sea
      *
      * @param e jQuery event object coming from the "Search" button
      */
-    do_search: function (e) {
-        if (this.headless && !this.has_defaults) {
-            return this.on_search([], [], []);
-        }
+    do_search: function () {
+        var domains = [], contexts = [], groupbys = [], errors = [];
 
-        if (e && e.preventDefault) { e.preventDefault(); }
-
-        var data = this.build_search_data();
-
-        if (data.errors.length) {
-            this.on_invalid(data.errors);
-            return;
-        }
-
-        this.on_search(data.domains, data.contexts, data.groupbys);
-    },
-    build_search_data: function() {
-        var domains = [],
-           contexts = [],
-             errors = [];
-
-        _.each(this.inputs, function (input) {
+        this.vs.searchQuery.each(function (facet) {
+            var field = facet.get('field');
             try {
-                var domain = input.get_domain();
+                var domain = field.get_domain(facet);
                 if (domain) {
                     domains.push(domain);
                 }
-
-                var context = input.get_context();
+                var context = field.get_context(facet);
                 if (context) {
                     contexts.push(context);
+                    groupbys.push(context);
                 }
             } catch (e) {
                 if (e instanceof openerp.web.search.Invalid) {
@@ -383,23 +602,11 @@ openerp.web.SearchView = openerp.web.OldWidget.extend(/** @lends openerp.web.Sea
             }
         });
 
-        // TODO: do we need to handle *fields* with group_by in their context?
-        var groupbys = _(this.enabled_filters)
-                .chain()
-                .map(function (filter) { return filter.get_context();})
-                .compact()
-                .value();
-
-        if (this.filter_data.contexts) {
-            contexts = this.filter_data.contexts.concat(contexts)
-        }
-        if (this.filter_data.domains) {
-            domains = this.filter_data.domains.concat(domains);
-        }
-        if (this.filter_data.groupbys) {
-            groupbys = this.filter_data.groupbys.concat(groupbys);
+        if (!_.isEmpty(errors)) {
+            this.on_invalid(errors);
+            return;
         }
-        return {domains: domains, contexts: contexts, errors: errors, groupbys: groupbys};
+        return this.on_search(domains, contexts, groupbys);
     },
     /**
      * Triggered after the SearchView has collected all relevant domains and
@@ -432,59 +639,31 @@ openerp.web.SearchView = openerp.web.OldWidget.extend(/** @lends openerp.web.Sea
      */
     on_invalid: function (errors) {
         this.do_notify(_t("Invalid Search"), _t("triggered from search view"));
-    },
-    /**
-     * @param {Boolean} [reload_view=true]
-     */
-    do_clear: function (reload_view) {
-        this.filter_data = {};
-        this.$element.find(".oe_search-view-filters-management").val('');
-
-        this.$element.find('.filter_label, .filter_icon').removeClass('enabled');
-        this.enabled_filters.splice(0);
-        var string = $('a.searchview_group_string');
-        _.each(string, function(str){
-            $(str).closest('div.searchview_group').removeClass("expanded").addClass('folded');
-         });
-        this.$element.find('table:last').hide();
-
-        $('.searchview_extended_groups_list').empty();
-        return $.async_when.apply(
-            null, _(this.inputs).invoke('clear')).pipe(
-                reload_view !== false ? this.on_clear : null);
-    },
-    /**
-     * Triggered when the search view gets cleared
-     *
-     * @event
-     */
-    on_clear: function () {
-        this.do_search();
-    },
-    /**
-     * Called by a filter propagating its state changes
-     *
-     * @param {openerp.web.search.Filter} filter a filter which got toggled
-     * @param {Boolean} default_enabled filter got enabled through the default values, at render time.
-     */
-    do_toggle_filter: function (filter, default_enabled) {
-        if (default_enabled || filter.is_enabled()) {
-            this.enabled_filters.push(filter);
-        } else {
-            this.enabled_filters = _.without(
-                this.enabled_filters, filter);
-        }
-
-        if (!default_enabled) {
-            // selecting a filter after initial loading automatically
-            // triggers refresh
-            this.$element.find('form').submit();
-        }
     }
 });
 
 /** @namespace */
 openerp.web.search = {};
+
+openerp.web.search.FilterGroupFacet = VS.ui.SearchFacet.extend({
+    events: _.extend({
+        'click': 'selectFacet'
+    }, VS.ui.SearchFacet.prototype.events),
+
+    render: function () {
+        this.setMode('not', 'editing');
+        this.setMode('not', 'selected');
+
+        var value = this.model.get('value');
+        this.$el.html(QWeb.render('SearchView.filters.facet', {
+            facet: this.model
+        }));
+        // virtual input so SearchFacet code has something to play with
+        this.box = $('<input>').val(value);
+
+        return this;
+    }
+});
 /**
  * Registry of search fields, called by :js:class:`openerp.web.SearchView` to
  * find and instantiate its field widgets.
@@ -540,47 +719,6 @@ openerp.web.search.Widget = openerp.web.OldWidget.extend( /** @lends openerp.web
     init: function (view) {
         this._super(view);
         this.view = view;
-    },
-    /**
-     * Sets and returns a globally unique identifier for the widget.
-     *
-     * If a prefix is specified, the identifier will be appended to it.
-     *
-     * @params prefix prefix sections, empty/falsy sections will be removed
-     */
-    make_id: function () {
-        this.element_id = _.uniqueId(
-            ['search'].concat(
-                _.compact(_.toArray(arguments)),
-                ['']).join('_'));
-        return this.element_id;
-    },
-    /**
-     * "Starts" the widgets. Called at the end of the rendering, this allows
-     * widgets to hook themselves to their view sections.
-     *
-     * On widgets, if they kept a reference to a view and have an element_id,
-     * will fetch and set their root element on $element.
-     */
-    start: function () {
-        this._super();
-        if (this.view && this.element_id) {
-            // id is unique, and no getElementById on elements
-            this.$element = $(document.getElementById(
-                this.element_id));
-        }
-    },
-    /**
-     * "Stops" the widgets. Called when the view destroys itself, this
-     * lets the widgets clean up after themselves.
-     */
-    destroy: function () {
-        delete this.view;
-        this._super();
-    },
-    render: function (defaults) {
-        // FIXME
-        return this._super(_.extend(this, {defaults: defaults}));
     }
 });
 openerp.web.search.add_expand_listener = function($root) {
@@ -597,15 +735,6 @@ openerp.web.search.Group = openerp.web.search.Widget.extend({
         this.attrs = view_section.attrs;
         this.lines = view.make_widgets(
             view_section.children, fields);
-        this.make_id('group');
-    },
-    start: function () {
-        this._super();
-        openerp.web.search.add_expand_listener(this.$element);
-        var widget_starts = _(this.lines).chain().flatten()
-                .map(function (widget) { return widget.start(); })
-            .value();
-        return $.when.apply(null, widget_starts);
     }
 });
 
@@ -621,6 +750,36 @@ openerp.web.search.Input = openerp.web.search.Widget.extend( /** @lends openerp.
         this.view.inputs.push(this);
         this.style = undefined;
     },
+    /**
+     * Fetch auto-completion values for the widget.
+     *
+     * The completion values should be an array of objects with keys category,
+     * label, value prefixed with an object with keys type=section and label
+     *
+     * @param {String} value value to complete
+     * @returns {jQuery.Deferred<null|Array>}
+     */
+    complete: function (value) {
+        return $.when(null)
+    },
+    /**
+     * Returns a VS.model.SearchFacet instance for the provided defaults if
+     * they apply to this widget, or null if they don't.
+     *
+     * This default implementation will try calling
+     * :js:func:`openerp.web.search.Input#facet_for` if the widget's name
+     * matches the input key
+     *
+     * @param {Object} defaults
+     * @returns {jQuery.Deferred<null|Object>}
+     */
+    facet_for_defaults: function (defaults) {
+        if (!this.attrs ||
+            !(this.attrs.name in defaults && defaults[this.attrs.name])) {
+            return $.when(null);
+        }
+        return this.facet_for(defaults[this.attrs.name]);
+    },
     get_context: function () {
         throw new Error(
             "get_context not implemented for widget " + this.attrs.type);
@@ -638,11 +797,7 @@ openerp.web.search.Input = openerp.web.search.Widget.extend( /** @lends openerp.
             }
         }
         this.attrs = attrs;
-    },
-    /**
-     * Specific clearing operations, if any
-     */
-    clear: function () {}
+    }
 });
 openerp.web.search.FilterGroup = openerp.web.search.Input.extend(/** @lends openerp.web.search.FilterGroup# */{
     template: 'SearchView.filters',
@@ -659,21 +814,51 @@ openerp.web.search.FilterGroup = openerp.web.search.Input.extend(/** @lends open
     init: function (filters, view) {
         this._super(view);
         this.filters = filters;
-        this.length = filters.length;
     },
     start: function () {
-        this._super();
-        _.each(this.filters, function (filter) {
-            filter.start();
+        this.$element.on('click', 'li', this.proxy('toggle_filter'));
+        return $.when(null);
+    },
+    facet_for_defaults: function (defaults) {
+        var fs = _(this.filters).filter(function (f) {
+            return f.attrs && f.attrs.name && !!defaults[f.attrs.name];
+        });
+        if (_.isEmpty(fs)) { return $.when(null); }
+        return $.when(new VS.model.SearchFacet({
+            category: 'q',
+            value: _(fs).map(function (f) {
+                return f.attrs.string || f.attrs.name }).join(' | '),
+            json: fs,
+            field: this,
+            app: this.view.vs
+        }));
+    },
+    /**
+     * Fetches contexts for all enabled filters in the group
+     *
+     * @param {VS.model.SearchFacet} facet
+     * @return {*} combined contexts of the enabled filters in this group
+     */
+    get_context: function (facet) {
+        var contexts = _(facet.get('json')).chain()
+            .map(function (filter) { return filter.attrs.context; })
+            .reject(_.isEmpty)
+            .value();
+
+        if (!contexts.length) { return; }
+        if (contexts.length === 1) { return contexts[0]; }
+        return _.extend(new openerp.web.CompoundContext, {
+            __contexts: contexts
         });
     },
-    get_context: function () { },
     /**
      * Handles domains-fetching for all the filters within it: groups them.
+     *
+     * @param {VS.model.SearchFacet} facet
+     * @return {*} combined domains of the enabled filters in this group
      */
-    get_domain: function () {
-        var domains = _(this.filters).chain()
-            .filter(function (filter) { return filter.is_enabled(); })
+    get_domain: function (facet) {
+        var domains = _(facet.get('json')).chain()
             .map(function (filter) { return filter.attrs.domain; })
             .reject(_.isEmpty)
             .value();
@@ -686,6 +871,44 @@ openerp.web.search.FilterGroup = openerp.web.search.Input.extend(/** @lends open
         return _.extend(new openerp.web.CompoundDomain(), {
             __domains: domains
         });
+    },
+    toggle_filter: function (e) {
+        // FIXME: oh god, my eyes, they hurt
+        var self = this, fs;
+        var filter = this.filters[$(e.target).index()];
+
+        var facet = this.view.vs.searchQuery.detect(function (f) {
+            return f.get('field') === self; });
+        if (facet) {
+            fs = facet.get('json');
+
+            if (_.include(fs, filter)) {
+                fs = _.without(fs, filter);
+            } else {
+                fs.push(filter);
+            }
+            if (_(fs).isEmpty()) {
+                this.view.vs.searchQuery.remove(facet, {trigger_search: true});
+            } else {
+                facet.set({
+                    json: fs,
+                    value: _(fs).map(function (f) {
+                        return f.attrs.string || f.attrs.name }).join(' | ')
+                });
+            }
+            return;
+        } else {
+            fs = [filter];
+        }
+
+        this.view.vs.searchQuery.add({
+            category: 'q',
+            value: _(fs).map(function (f) {
+                return f.attrs.string || f.attrs.name }).join(' | '),
+            json: fs,
+            field: this,
+            app: this.view.vs
+        });
     }
 });
 openerp.web.search.Filter = openerp.web.search.Input.extend(/** @lends openerp.web.search.Filter# */{
@@ -694,6 +917,10 @@ openerp.web.search.Filter = openerp.web.search.Input.extend(/** @lends openerp.w
      * Implementation of the OpenERP filters (button with a context and/or
      * a domain sent as-is to the search view)
      *
+     * Filters are only attributes holder, the actual work (compositing
+     * domains and contexts, converting between facets and filters) is
+     * performed by the filter group.
+     *
      * @constructs openerp.web.search.Filter
      * @extends openerp.web.search.Input
      *
@@ -703,48 +930,9 @@ openerp.web.search.Filter = openerp.web.search.Input.extend(/** @lends openerp.w
     init: function (node, view) {
         this._super(view);
         this.load_attrs(node.attrs);
-        this.classes = [this.attrs.string ? 'filter_label' : 'filter_icon'];
-        this.make_id('filter', this.attrs.name);
-    },
-    start: function () {
-        this._super();
-        var self = this;
-        this.$element.click(function (e) {
-            $(this).toggleClass('enabled');
-            self.view.do_toggle_filter(self);
-        });
-    },
-    /**
-     * Returns whether the filter is currently enabled (in use) or not.
-     *
-     * @returns a boolean
-     */
-    is_enabled:function () {
-        return this.$element.hasClass('enabled');
     },
-    /**
-     * If the filter is present in the defaults (and has a truthy value),
-     * enable the filter.
-     *
-     * @param {Object} defaults the search view's default values
-     */
-    render: function (defaults) {
-        if (this.attrs.name && defaults[this.attrs.name]) {
-            this.classes.push('enabled');
-            this.view.do_toggle_filter(this, true);
-        }
-        return this._super(defaults);
-    },
-    get_context: function () {
-        if (!this.is_enabled()) {
-            return;
-        }
-        return this.attrs.context;
-    },
-    /**
-     * Does not return anything: filter domain is handled at the FilterGroup
-     * level
-     */
+    facet_for: function () { return $.when(null); },
+    get_context: function () { },
     get_domain: function () { }
 });
 openerp.web.search.Field = openerp.web.search.Input.extend( /** @lends openerp.web.search.Field# */ {
@@ -761,25 +949,21 @@ openerp.web.search.Field = openerp.web.search.Input.extend( /** @lends openerp.w
     init: function (view_section, field, view) {
         this._super(view);
         this.load_attrs(_.extend({}, field, view_section.attrs));
-        this.filters = new openerp.web.search.FilterGroup(_.compact(_.map(
-            view_section.children, function (filter_node) {
-                if (filter_node.attrs.string &&
-                        typeof console !== 'undefined' && console.debug) {
-                    console.debug("Filter-in-field with a 'string' attribute "
-                                + "in view", view);
-                }
-                delete filter_node.attrs.string;
-                return new openerp.web.search.Filter(
-                    filter_node, view);
-        })), view);
-        this.make_id('input', field.type, this.attrs.name);
     },
-    start: function () {
-        this._super();
-        this.filters.start();
+    facet_for: function (value) {
+        return $.when(new VS.model.SearchFacet({
+            category: this.attrs.string || this.attrs.name,
+            value: String(value),
+            json: value,
+            field: this,
+            app: this.view.vs
+        }));
     },
-    get_context: function () {
-        var val = this.get_value();
+    get_value: function (facet) {
+        return facet.value();
+    },
+    get_context: function (facet) {
+        var val = this.get_value(facet);
         // A field needs a value to be "active", and a context to send when
         // active
         var has_value = (val !== null && val !== '');
@@ -787,9 +971,8 @@ openerp.web.search.Field = openerp.web.search.Input.extend( /** @lends openerp.w
         if (!(has_value && context)) {
             return;
         }
-        return _.extend(
-            {}, context,
-            {own_values: {self: val}});
+        return new openerp.web.CompoundContext(context)
+                .set_eval_context({self: val});
     },
     /**
      * Function creating the returned domain for the field, override this
@@ -802,11 +985,11 @@ openerp.web.search.Field = openerp.web.search.Input.extend( /** @lends openerp.w
      * @param {Number|String} value parsed value for the field
      * @returns {Array<Array>} domain to include in the resulting search
      */
-    make_domain: function (name, operator, value) {
-        return [[name, operator, value]];
+    make_domain: function (name, operator, facet) {
+        return [[name, operator, facet.value()]];
     },
-    get_domain: function () {
-        var val = this.get_value();
+    get_domain: function (facet) {
+        var val = this.get_value(facet);
         if (val === null || val === '') {
             return;
         }
@@ -816,9 +999,10 @@ openerp.web.search.Field = openerp.web.search.Input.extend( /** @lends openerp.w
             return this.make_domain(
                 this.attrs.name,
                 this.attrs.operator || this.default_operator,
-                val);
+                facet);
         }
-        return _.extend({}, domain, {own_values: {self: val}});
+        return new openerp.web.CompoundDomain(domain)
+                .set_eval_context({self: val});
     }
 });
 /**
@@ -833,8 +1017,18 @@ openerp.web.search.Field = openerp.web.search.Input.extend( /** @lends openerp.w
  */
 openerp.web.search.CharField = openerp.web.search.Field.extend( /** @lends openerp.web.search.CharField# */ {
     default_operator: 'ilike',
-    get_value: function () {
-        return this.$element.val();
+    complete: function (value) {
+        if (_.isEmpty(value)) { return $.when(null); }
+        var label = _.str.sprintf(_.str.escapeHTML(
+            _t("Search %(field)s for: %(value)s")), {
+                field: '<em>' + this.attrs.string + '</em>',
+                value: '<strong>' + _.str.escapeHTML(value) + '</strong>'});
+        return $.when([{
+            category: this.attrs.string,
+            label: label,
+            value: value,
+            field: this
+        }]);
     }
 });
 openerp.web.search.NumberField = openerp.web.search.Field.extend(/** @lends openerp.web.search.NumberField# */{
@@ -904,42 +1098,42 @@ openerp.web.search.SelectionField = openerp.web.search.Field.extend(/** @lends o
             return !item[1];
         });
     },
-    get_value: function () {
-        var index = parseInt(this.$element.val(), 10);
-        if (isNaN(index)) { return null; }
-        var value = this.attrs.selection[index][0];
-        if (value === false) { return null; }
-        return value;
-    },
-    /**
-     * The selection field needs a default ``false`` value in case none is
-     * provided, so that selector options with a ``false`` value (convention
-     * for explicitly empty options) get selected by default rather than the
-     * first (value-holding) option in the selection.
-     *
-     * @param {Object} defaults search default values
-     */
-    render: function (defaults) {
-        if (!defaults[this.attrs.name]) {
-            defaults[this.attrs.name] = false;
-        }
-        return this._super(defaults);
-    },
-    clear: function () {
-        var self = this, d = $.Deferred(), selection = this.attrs.selection;
-        for(var index=0; index<selection.length; ++index) {
-            var item = selection[index];
-            if (!item[1]) {
-                setTimeout(function () {
-                    // won't override mutable, because we immediately bail out
-                    //noinspection JSReferencingMutableVariableFromClosure
-                    self.$element.val(index);
-                    d.resolve();
-                }, 0);
-                return d.promise();
-            }
-        }
-        return d.resolve().promise();
+    complete: function (needle) {
+        var self = this;
+        var results = _(this.attrs.selection).chain()
+            .filter(function (sel) {
+                var value = sel[0], label = sel[1];
+                if (!value) { return false; }
+                return label.toLowerCase().indexOf(needle.toLowerCase()) !== -1;
+            })
+            .map(function (sel) {
+                return {
+                    category: self.attrs.string,
+                    field: self,
+                    value: sel[1],
+                    json: sel[0]
+                };
+            }).value();
+        if (_.isEmpty(results)) { return $.when(null); }
+        return $.when.apply(null, [{
+            category: this.attrs.string
+        }].concat(results));
+    },
+    facet_for: function (value) {
+        var match = _(this.attrs.selection).detect(function (sel) {
+            return sel[0] === value;
+        });
+        if (!match) { return $.when(null); }
+        return $.when(new VS.model.SearchFacet({
+            category: this.attrs.string,
+            value: match[1],
+            json: match[0],
+            field: this,
+            app: this.view.app
+        }));
+    },
+    get_value: function (facet) {
+        return facet.get('json');
     }
 });
 openerp.web.search.BooleanField = openerp.web.search.SelectionField.extend(/** @lends openerp.web.search.BooleanField# */{
@@ -954,25 +1148,8 @@ openerp.web.search.BooleanField = openerp.web.search.SelectionField.extend(/** @
             ['false', _t("No")]
         ];
     },
-    /**
-     * Search defaults likely to be boolean values (for a boolean field).
-     *
-     * In the HTML, we only want/get strings, and our strings here are ``true``
-     * and ``false``, so ensure we use precisely those by truth-testing the
-     * default value (iif there is one in the view's defaults).
-     *
-     * @param {Object} defaults default values for this search view
-     * @returns {String} rendered boolean field
-     */
-    render: function (defaults) {
-        var name = this.attrs.name;
-        if (name in defaults) {
-            defaults[name] = defaults[name] ? "true" : "false";
-        }
-        return this._super(defaults);
-    },
-    get_value: function () {
-        switch (this._super()) {
+    get_value: function (facet) {
+        switch (this._super(facet)) {
             case 'false': return false;
             case 'true': return true;
             default: return null;
@@ -998,9 +1175,6 @@ openerp.web.search.DateField = openerp.web.search.Field.extend(/** @lends opener
     },
     get_value: function () {
         return this.datewidget.get_value() || null;
-    },
-    clear: function () {
-        this.datewidget.set_value(false);
     }
 });
 /**
@@ -1023,76 +1197,57 @@ openerp.web.search.DateTimeField = openerp.web.search.DateField.extend(/** @lend
 openerp.web.search.ManyToOneField = openerp.web.search.CharField.extend({
     init: function (view_section, field, view) {
         this._super(view_section, field, view);
+        this.model = new openerp.web.Model(this.attrs.relation);
+    },
+    complete: function (needle) {
         var self = this;
-        this.got_name = $.Deferred().then(function () {
-            self.$element.val(self.name);
+        // TODO: context
+        // FIXME: "concurrent" searches (multiple requests, mis-ordered responses)
+        return this.model.call('name_search', [], {
+            name: needle,
+            limit: 8,
+            context: {}
+        }).pipe(function (results) {
+            if (_.isEmpty(results)) { return null; }
+            return [{category: self.attrs.string}].concat(
+                _(results).map(function (result) {
+                    return {
+                        category: self.attrs.string,
+                        value: result[1],
+                        json: result[0],
+                        field: self
+                    };
+                }));
         });
-        this.dataset = new openerp.web.DataSet(
-                this.view, this.attrs['relation']);
     },
-    start: function () {
-        this._super();
-        this.setup_autocomplete();
-        var started = $.Deferred();
-        this.got_name.then(function () { started.resolve();},
-                           function () { started.resolve(); });
-        return started.promise();
-    },
-    setup_autocomplete: function () {
+    facet_for: function (value) {
         var self = this;
-        this.$element.autocomplete({
-            source: function (req, resp) {
-                if (self.abort_last) {
-                    self.abort_last();
-                    delete self.abort_last;
-                }
-                self.dataset.name_search(
-                    req.term, self.attrs.domain, 'ilike', 8, function (data) {
-                        resp(_.map(data, function (result) {
-                            return {id: result[0], label: result[1]}
-                        }));
-                });
-                self.abort_last = self.dataset.abort_last;
-            },
-            select: function (event, ui) {
-                self.id = ui.item.id;
-                self.name = ui.item.label;
-            },
-            delay: 0
-        })
-    },
-    on_name_get: function (name_get) {
-        if (!name_get.length) {
-            delete this.id;
-            this.got_name.reject();
-            return;
-        }
-        this.name = name_get[0][1];
-        this.got_name.resolve();
-    },
-    render: function (defaults) {
-        if (defaults[this.attrs.name]) {
-            this.id = defaults[this.attrs.name];
-            if (this.id instanceof Array)
-                this.id = this.id[0];
-            // TODO: maybe this should not be completely removed
-            delete defaults[this.attrs.name];
-            this.dataset.name_get([this.id], $.proxy(this, 'on_name_get'));
-        } else {
-            this.got_name.reject();
+        if (value instanceof Array) {
+            return $.when(new VS.model.SearchFacet({
+                category: this.attrs.string,
+                value: value[1],
+                json: value[0],
+                field: this,
+                app: this.view.vs
+            }));
         }
-        return this._super(defaults);
+        return this.model.call('name_get', [value], {}).pipe(function (names) {
+                return new VS.model.SearchFacet({
+                category: self.attrs.string,
+                value: names[0][1],
+                json: names[0][0],
+                field: self,
+                app: self.view.vs
+            });
+        })
     },
-    make_domain: function (name, operator, value) {
-        if (this.id && this.name) {
-            if (value === this.name) {
-                return [[name, '=', this.id]];
-            } else {
-                delete this.id;
-                delete this.name;
-            }
+    make_domain: function (name, operator, facet) {
+        // ``json`` -> actual auto-completed id
+        if (facet.get('json')) {
+            return [[name, '=', facet.get('json')]];
         }
-        return this._super(name, operator, value);
+
+        return this._super(name, operator, facet);
     }
 });