summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAaron Bohy <aab@odoo.com>2019-02-05 12:36:17 +0000
committerMathieu Duckerts-Antoine <dam@odoo.com>2019-02-13 12:23:28 +0000
commit3a5f6bfa9f74a0f038df4f22b97061c436c01281 (patch)
tree72a9fa8362ac46fdbceb59fe9d53756c9bbd0884
parent33207ed452cb1f3f2dc05a447a137b8cc8cee9ed (diff)
[IMP] web: add kanban searchPanel
This rev. defines a searchPanel widget used in Kanban views to refine search according to specific dimensions. This wigdet is displayed as a sidebar to the left of the kanban view. Part of task 1892462 Co-authored-by: Aaron Bohy <aab@odoo.com> Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com> Co-authored-by: Raphael Collet <rco@odoo.com>
-rw-r--r--addons/web/models/models.py155
-rw-r--r--addons/web/static/src/js/views/abstract_view.js5
-rw-r--r--addons/web/static/src/js/views/basic/basic_view.js4
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_controller.js65
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js11
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_view.js144
-rw-r--r--addons/web/static/src/js/views/kanban/search_panel.js527
-rw-r--r--addons/web/static/src/scss/kanban_view.scss96
-rw-r--r--addons/web/static/src/xml/kanban.xml105
-rw-r--r--addons/web/static/tests/helpers/mock_server.js119
-rw-r--r--addons/web/static/tests/views/kanban_mobile_tests.js29
-rw-r--r--addons/web/static/tests/views/search_panel_tests.js1448
-rw-r--r--addons/web/views/webclient_templates.xml2
-rw-r--r--doc/reference/views.rst51
14 files changed, 2748 insertions, 13 deletions
diff --git a/addons/web/models/models.py b/addons/web/models/models.py
index b055af88f2e..2a404c58df0 100644
--- a/addons/web/models/models.py
+++ b/addons/web/models/models.py
@@ -3,13 +3,17 @@ import babel.dates
import pytz
from odoo import _, api, fields, models
+from odoo.osv.expression import AND
from odoo.tools import lazy
+from odoo.exceptions import UserError
+
class IrActionsActWindowView(models.Model):
_inherit = 'ir.actions.act_window.view'
view_mode = fields.Selection(selection_add=[('qweb', 'QWeb')])
+
class Base(models.AbstractModel):
_inherit = 'base'
@@ -114,3 +118,154 @@ class Base(models.AbstractModel):
if r['type'] == 'qweb':
r['arch'] = '<qweb/>'
return r
+
+ @api.model
+ def search_panel_select_range(self, field_name):
+ """
+ Return possible values of the field field_name (case select="one")
+ and the parent field (if any) used to hierarchize them.
+
+ :param field_name: the name of a many2one category field
+ :return: {
+ 'parent_field': parent field on the comodel of field, or False
+ 'values': array of dictionaries containing some info on the records
+ available on the comodel of the field 'field_name'.
+ The display name (and possibly parent_field) are fetched.
+ }
+ """
+ field = self._fields[field_name]
+ supported_types = ['many2one']
+ if field.type not in supported_types:
+ raise UserError(_('Only types %(supported_types)s are supported for category (found type %(field_type)s)') % ({
+ 'supported_types': supported_types, 'field_type': field.type}))
+
+ Comodel = self.env[field.comodel_name]
+ fields = ['display_name']
+ parent_name = Comodel._parent_name if Comodel._parent_name in Comodel._fields else False
+ if parent_name:
+ fields.append(parent_name)
+ return {
+ 'parent_field': parent_name,
+ 'values': Comodel.search_read([], fields),
+ }
+
+ @api.model
+ def search_panel_select_multi_range(self, field_name, **kwargs):
+ """
+ Return possible values of the field field_name (case select="multi"),
+ possibly with counters and groups.
+
+ :param field_name: the name of a filter field;
+ possible types are many2one, many2many, selection.
+ :param search_domain: base domain of search
+ :param category_domain: domain generated by categories
+ :param filter_domain: domain generated by filters
+ :param comodel_domain: domain of field values (if relational)
+ :param group_by: extra field to read on comodel, to group comodel records
+ :param disable_counters: whether to count records by value
+ :return: a list of possible values, each being a dict with keys
+ 'id' (value),
+ 'name' (value label),
+ 'count' (how many records with that value),
+ 'group_id' (value of group),
+ 'group_name' (label of group).
+ """
+ field = self._fields[field_name]
+ supported_types = ['many2one', 'many2many', 'selection']
+ if field.type not in supported_types:
+ raise UserError(_('Only types %(supported_types)s are supported for filter (found type %(field_type)s)') % ({
+ 'supported_types': supported_types, 'field_type': field.type}))
+
+ Comodel = self.env.get(field.comodel_name)
+
+ model_domain = AND([
+ kwargs.get('search_domain', []),
+ kwargs.get('category_domain', []),
+ kwargs.get('filter_domain', []),
+ [(field_name, '!=', False)],
+ ])
+ comodel_domain = kwargs.get('comodel_domain', [])
+ disable_counters = kwargs.get('disable_counters', False)
+
+ group_by = kwargs.get('group_by', False)
+ if group_by:
+ # determine the labeling of values returned by the group_by field
+ group_by_field = Comodel._fields[group_by]
+
+ if group_by_field.type == 'many2one':
+ def group_id_name(value):
+ return value or (False, _("Not Set"))
+
+ elif group_by_field.type == 'selection':
+ desc = Comodel.fields_get([group_by])[group_by]
+ group_by_selection = dict(desc['selection'])
+ group_by_selection[False] = _("Not Set")
+
+ def group_id_name(value):
+ return value, group_by_selection[value]
+
+ else:
+ def group_id_name(value):
+ return (value, value) if value else (False, _("Not Set"))
+
+ # get filter_values
+ filter_values = []
+
+ if field.type == 'many2one':
+ counters = {}
+ if not disable_counters:
+ groups = self.read_group(model_domain, [field_name], [field_name])
+ counters = {
+ group[field_name][0]: group[field_name + '_count']
+ for group in groups
+ }
+ # retrieve all possible values, and return them with their label and counter
+ field_names = ['display_name', group_by] if group_by else ['display_name']
+ records = Comodel.search_read(comodel_domain, field_names)
+ for record in records:
+ record_id = record['id']
+ values = {
+ 'id': record_id,
+ 'name': record['display_name'],
+ 'count': counters.get(record_id, 0),
+ }
+ if group_by:
+ values['group_id'], values['group_name'] = group_id_name(record[group_by])
+ filter_values.append(values)
+
+ elif field.type == 'many2many':
+ # retrieve all possible values, and return them with their label and counter
+ field_names = ['display_name', group_by] if group_by else ['display_name']
+ records = Comodel.search_read(comodel_domain, field_names)
+ for record in records:
+ record_id = record['id']
+ values = {
+ 'id': record_id,
+ 'name': record['display_name'],
+ 'count': 0,
+ }
+ if not disable_counters:
+ count_domain = AND([model_domain, [(field_name, 'in', record_id)]])
+ values['count'] = self.search_count(count_domain)
+ if group_by:
+ values['group_id'], values['group_name'] = group_id_name(record[group_by])
+ filter_values.append(values)
+
+ elif field.type == 'selection':
+ counters = {}
+ if not disable_counters:
+ groups = self.read_group(model_domain, [field_name], [field_name])
+ counters = {
+ group[field_name]: group[field_name + '_count']
+ for group in groups
+ }
+ # retrieve all possible values, and return them with their label and counter
+ selection = self.fields_get([field_name])[field_name]
+ for value, label in selection:
+ filter_values.append({
+ 'id': value,
+ 'name': label,
+ 'count': counters.get(value, 0),
+ })
+
+ return filter_values
diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js
index 085571a200f..72108e3c8ad 100644
--- a/addons/web/static/src/js/views/abstract_view.js
+++ b/addons/web/static/src/js/views/abstract_view.js
@@ -192,10 +192,6 @@ var AbstractView = Factory.extend({
}
var _super = this._super.bind(this);
return $.when(def).then(function (controlPanel) {
- if (controlPanel) {
- var searchQuery = controlPanel.getSearchQuery();
- self._updateMVCParams(searchQuery);
- }
// get the parent of the model if it already exists, as _super will
// set the new controller as parent, which we don't want
var modelParent = self.model && self.model.getParent();
@@ -249,6 +245,7 @@ var AbstractView = Factory.extend({
return controlPanelView.getController(parent).then(function (controlPanel) {
self.controllerParams.controlPanel = controlPanel;
return controlPanel.appendTo(document.createDocumentFragment()).then(function () {
+ self._updateMVCParams(controlPanel.getSearchQuery());
return controlPanel;
});
});
diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js
index 39444fdfd75..313bed44708 100644
--- a/addons/web/static/src/js/views/basic/basic_view.js
+++ b/addons/web/static/src/js/views/basic/basic_view.js
@@ -393,7 +393,9 @@ var BasicView = AbstractView.extend({
for (var dependency_name in deps) {
var dependency_dict = {name: dependency_name, type: deps[dependency_name].type};
if (!(dependency_name in fieldsInfo)) {
- fieldsInfo[dependency_name] = _.extend({}, dependency_dict, {options: deps[dependency_name].options || {}});
+ fieldsInfo[dependency_name] = _.extend({}, dependency_dict, {
+ options: deps[dependency_name].options || {},
+ });
}
if (!(dependency_name in fields)) {
fields[dependency_name] = dependency_dict;
diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js
index b51c32aa770..8c6525981f2 100644
--- a/addons/web/static/src/js/views/kanban/kanban_controller.js
+++ b/addons/web/static/src/js/views/kanban/kanban_controller.js
@@ -32,6 +32,7 @@ var KanbanController = BasicController.extend({
kanban_load_records: '_onLoadColumnRecords',
column_toggle_fold: '_onToggleColumn',
kanban_column_records_toggle_active: '_onToggleActiveRecords',
+ search_panel_domain_updated: '_onSearchPanelDomainUpdated',
}),
events: _.extend({}, BasicController.prototype.events, {
click: '_onClick',
@@ -41,13 +42,31 @@ var KanbanController = BasicController.extend({
* @param {Object} params
* @param {boolean} params.quickCreateEnabled set to false to disable the
* quick create feature
+ * @param {SearchPanel} [params.searchPanel]
+ * @param {Array[]} [params.controlPanelDomain=[]] initial domain coming
+ * from the controlPanel
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
-
this.on_create = params.on_create;
this.hasButtons = params.hasButtons;
this.quickCreateEnabled = params.quickCreateEnabled;
+
+ // the following attributes are used when there is a searchPanel
+ this._searchPanel = params.searchPanel;
+ this.controlPanelDomain = params.controlPanelDomain || [];
+ this.searchPanelDomain = this._searchPanel ? this._searchPanel.getDomain() : [];
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ if (this._searchPanel) {
+ this.$('.o_content')
+ .addClass('o_kanban_with_searchpanel')
+ .prepend(this._searchPanel.$el);
+ }
+ return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
@@ -64,11 +83,37 @@ var KanbanController = BasicController.extend({
widget: this,
}));
this.$buttons.on('click', 'button.o-kanban-button-new', this._onButtonNew.bind(this));
- this.$buttons.on('keydown',this._onButtonsKeyDown.bind(this));
+ this.$buttons.on('keydown', this._onButtonsKeyDown.bind(this));
this._updateButtons();
this.$buttons.appendTo($node);
}
},
+ /**
+ * Override to add the domain coming from the searchPanel (if any) to the
+ * domain coming from the controlPanel.
+ *
+ * @override
+ */
+ update: function (params) {
+ if (!this._searchPanel) {
+ return this._super.apply(this, arguments);
+ }
+ var self = this;
+ if (params.domain) {
+ this.controlPanelDomain = params.domain;
+ }
+ // do not re-render the view as soon as records have been fetched, but
+ // wait for the searchPanel to be ready as well, such that the view
+ // isn't re-rendered before the searchPanel
+ params.noRender = true;
+ params.domain = this.controlPanelDomain.concat(this.searchPanelDomain);
+ var superProm = this._super.apply(this, arguments);
+ var searchPanelProm = this._updateSearchPanel();
+ return $.when(superProm, searchPanelProm).then(function () {
+ // searchPanel has been re-rendered, so re-render the view
+ return self.renderer.render();
+ });
+ },
//--------------------------------------------------------------------------
// Private
@@ -198,6 +243,13 @@ var KanbanController = BasicController.extend({
this.$buttons.find('.o-kanban-button-new').toggleClass('o_hidden', createHidden);
}
},
+ /**
+ * @private
+ * @returns {$.Promise}
+ */
+ _updateSearchPanel: function () {
+ return this._searchPanel.update({searchDomain: this.controlPanelDomain});
+ },
//--------------------------------------------------------------------------
// Handlers
@@ -456,6 +508,15 @@ var KanbanController = BasicController.extend({
/**
* @private
* @param {OdooEvent} ev
+ * @param {Array[]} ev.data.domain the current domain of the searchPanel
+ */
+ _onSearchPanelDomainUpdated: function (ev) {
+ this.searchPanelDomain = ev.data.domain;
+ this.reload({offset: 0});
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
* @param {boolean} [ev.data.openQuickCreate=false] if true, opens the
* QuickCreate in the toggled column (it assumes that we are opening it)
*/
diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js
index 51d75a167c7..252b4587af6 100644
--- a/addons/web/static/src/js/views/kanban/kanban_renderer.js
+++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js
@@ -167,6 +167,17 @@ var KanbanRenderer = BasicRenderer.extend({
this._toggleNoContentHelper();
},
/**
+ * Allow the rendering to be triggered from outside. This is used for kanban
+ * views with a searchPanel, to synchronize updates (the view is updated
+ * with param 'noRender', so that it is not re-rendered before the
+ * searchPanel).
+ *
+ * @returns {$.Promise}
+ */
+ render: function () {
+ return this._render();
+ },
+ /**
* Updates a given column with its new state.
*
* @param {string} localID the column id
diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js
index fd40ca7df93..b7484e7d409 100644
--- a/addons/web/static/src/js/views/kanban/kanban_view.js
+++ b/addons/web/static/src/js/views/kanban/kanban_view.js
@@ -2,12 +2,14 @@ odoo.define('web.KanbanView', function (require) {
"use strict";
var BasicView = require('web.BasicView');
-var core = require('web.core');
var config = require('web.config');
-var KanbanModel = require('web.KanbanModel');
-var KanbanRenderer = require('web.KanbanRenderer');
+var core = require('web.core');
var KanbanController = require('web.KanbanController');
var kanbanExamplesRegistry = require('web.kanban_examples_registry');
+var KanbanModel = require('web.KanbanModel');
+var KanbanRenderer = require('web.KanbanRenderer');
+var pyUtils = require('web.py_utils');
+var SearchPanel = require('web.SearchPanel');
var utils = require('web.utils');
var _lt = core._lt;
@@ -21,6 +23,7 @@ var KanbanView = BasicView.extend({
Model: KanbanModel,
Controller: KanbanController,
Renderer: KanbanRenderer,
+ SearchPanel: SearchPanel,
},
jsLibs: [],
viewType: 'kanban',
@@ -29,7 +32,10 @@ var KanbanView = BasicView.extend({
* @constructor
*/
init: function (viewInfo, params) {
+ this.searchPanelSections = Object.create(null);
+
this._super.apply(this, arguments);
+
this.loadParams.limit = this.loadParams.limit || 40;
// in mobile, columns are lazy-loaded, so set 'openGroupByDefault' to
// false so that they will won't be loaded by the initial load
@@ -81,9 +87,31 @@ var KanbanView = BasicView.extend({
this.controllerParams.hasButtons = true;
this.controllerParams.quickCreateEnabled = this.rendererParams.quickCreateEnabled;
+
if (config.device.isMobile) {
this.jsLibs.push('/web/static/lib/jquery.touchSwipe/jquery.touchSwipe.js');
}
+
+ this.hasSearchPanel = !_.isEmpty(this.searchPanelSections);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override to set the controller as parent of the optional searchPanel
+ *
+ * @override
+ */
+ getController: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function (controller) {
+ if (self.hasSearchPanel) {
+ self.controllerParams.searchPanel.setParent(controller);
+ }
+ return controller;
+ });
},
//--------------------------------------------------------------------------
@@ -91,6 +119,53 @@ var KanbanView = BasicView.extend({
//--------------------------------------------------------------------------
/**
+ * Override to create the searchPanel (if necessary) with the domain coming
+ * from the controlPanel
+ *
+ * @override
+ * @private
+ */
+ _createControlPanel: function (parent) {
+ if (!this.hasSearchPanel) {
+ return this._super.apply(this, arguments);
+ }
+ var self = this;
+ return this._super.apply(this, arguments).then(function (controlPanel) {
+ return self._createSearchPanel(parent).then(function () {
+ return controlPanel;
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {Widget} parent
+ * @returns {$.Promise} resolved when the searchPanel is ready
+ */
+ _createSearchPanel: function (parent) {
+ var self = this;
+ var defaultCategoryValues = {};
+ Object.keys(this.loadParams.context).forEach(function (key) {
+ var match = /^searchpanel_default_(.*)$/.exec(key);
+ if (match) {
+ defaultCategoryValues[match[1]] = self.loadParams.context[key];
+ }
+ });
+ var controlPanelDomain = this.loadParams.domain;
+ var searchPanel = new this.config.SearchPanel(parent, {
+ defaultCategoryValues: defaultCategoryValues,
+ fields: this.fields,
+ model: this.loadParams.modelName,
+ searchDomain: controlPanelDomain,
+ sections: this.searchPanelSections,
+ });
+ this.controllerParams.searchPanel = searchPanel;
+ this.controllerParams.controlPanelDomain = controlPanelDomain;
+ return searchPanel.appendTo(document.createDocumentFragment()).then(function () {
+ var searchPanelDomain = searchPanel.getDomain();
+ self.loadParams.domain = controlPanelDomain.concat(searchPanelDomain);
+ });
+ },
+ /**
* @private
* @param {Object} viewInfo
* @returns {boolean} true iff the quick create feature is not explicitely
@@ -105,10 +180,71 @@ var KanbanView = BasicView.extend({
}
return true;
},
+ /**
+ * Override to handle nodes with tagname 'searchpanel'.
+ *
+ * @override
+ * @private
+ */
+ _processNode: function (node, fv) {
+ if (node.tag === 'searchpanel') {
+ if (!config.device.isMobile) {
+ this._processSearchPanelNode(node, fv);
+ }
+ return false;
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Populate this.searchPanelSections with category/filter descriptions.
+ *
+ * @private
+ * @param {Object} node
+ * @param {Object} fv
+ */
+ _processSearchPanelNode: function (node, fv) {
+ var self = this;
+ node.children.forEach(function (childNode, index) {
+ if (childNode.tag !== 'field') {
+ return;
+ }
+ if (childNode.attrs.invisible === "1") {
+ return;
+ }
+ var fieldName = childNode.attrs.name;
+ var type = childNode.attrs.select === 'multi' ? 'filter' : 'category';
+
+ var sectionId = _.uniqueId('section_');
+ var section = {
+ color: childNode.attrs.color,
+ description: childNode.attrs.string || fv.fields[fieldName].string,
+ fieldName: fieldName,
+ icon: childNode.attrs.icon,
+ id: sectionId,
+ index: index,
+ type: type,
+ };
+ if (section.type === 'category') {
+ section.icon = section.icon || 'fa-folder';
+ } else if (section.type === 'filter') {
+ section.disableCounters = !!pyUtils.py_eval(childNode.attrs.disable_counters || '0');
+ section.domain = childNode.attrs.domain || '[]';
+ section.groupBy = childNode.attrs.groupby;
+ section.icon = section.icon || 'fa-filter';
+ }
+ self.searchPanelSections[sectionId] = section;
+ });
+ },
+ /**
+ * @override
+ * @private
+ */
_updateMVCParams: function () {
this._super.apply(this, arguments);
var defaultGroupBy = this.arch.attrs.default_group_by;
- this.loadParams.groupBy = defaultGroupBy ? [defaultGroupBy] : (this.loadParams.groupedBy || []);
+ this.loadParams.groupBy = defaultGroupBy ?
+ [defaultGroupBy] :
+ (this.loadParams.groupedBy || []);
},
});
diff --git a/addons/web/static/src/js/views/kanban/search_panel.js b/addons/web/static/src/js/views/kanban/search_panel.js
new file mode 100644
index 00000000000..9062714e0c1
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/search_panel.js
@@ -0,0 +1,527 @@
+odoo.define('web.SearchPanel', function (require) {
+"use strict";
+
+/**
+ * This file defines the SearchPanel widget for Kanban. It allows to
+ * filter/manage data easily.
+ */
+
+var core = require('web.core');
+var Domain = require('web.Domain');
+var Widget = require('web.Widget');
+
+var qweb = core.qweb;
+
+var SearchPanel = Widget.extend({
+ className: 'o_search_panel',
+ events: {
+ 'click .o_search_panel_category_value header': '_onCategoryValueClicked',
+ 'click .o_search_panel_category_value .o_toggle_fold': '_onToggleFoldCategory',
+ 'click .o_search_panel_filter_group .o_toggle_fold': '_onToggleFoldFilterGroup',
+ 'change .o_search_panel_filter_value > div > input': '_onFilterValueChanged',
+ 'change .o_search_panel_filter_group > div > input': '_onFilterGroupChanged',
+ },
+
+ /**
+ * @override
+ * @param {Object} params
+ * @param {Object} [params.defaultCategoryValues={}] the category value to
+ * activate by default, for each category
+ * @param {Object} params.fields
+ * @param {string} params.model
+ * @param {Object} params.sections
+ * @param {Array[]} params.searchDomain domain coming from controlPanel
+ */
+ init: function (parent, params) {
+ this._super.apply(this, arguments);
+
+ this.categories = _.pick(params.sections, function (section) {
+ return section.type === 'category';
+ });
+ this.filters = _.pick(params.sections, function (section) {
+ return section.type === 'filter';
+ });
+
+ this.defaultCategoryValues = params.defaultCategoryValues || {};
+ this.fields = params.fields;
+ this.model = params.model;
+ this.searchDomain = params.searchDomain;
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ var loadProm = this._fetchCategories().then(function () {
+ return self._fetchFilters();
+ });
+ return $.when(loadProm, this._super.apply(this, arguments));
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this._render();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {Array[]} the current searchPanel domain based on active
+ * categories and checked filters
+ */
+ getDomain: function () {
+ return this._getCategoryDomain().concat(this._getFilterDomain());
+ },
+ /**
+ * Reload the filters and re-render. Note that we only reload the filters if
+ * the controlPanel domain or searchPanel domain has changed.
+ *
+ * @param {Object} params
+ * @param {Array[]} params.searchDomain domain coming from controlPanel
+ * @returns {$.Promise}
+ */
+ update: function (params) {
+ var currentSearchDomainStr = JSON.stringify(this.searchDomain);
+ var newSearchDomainStr = JSON.stringify(params.searchDomain);
+ var def;
+ if (this.needReload || (currentSearchDomainStr !== newSearchDomainStr)) {
+ this.needReload = false;
+ this.searchDomain = params.searchDomain;
+ def = this._fetchFilters();
+ }
+ return $.when(def).then(this._render.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} categoryId
+ * @param {Object[]} values
+ */
+ _createCategoryTree: function (categoryId, values) {
+ var category = this.categories[categoryId];
+ var parentField = category.parentField;
+
+ category.values = {};
+ values.forEach(function (value) {
+ category.values[value.id] = _.extend({}, value, {
+ childrenIds: [],
+ folded: true,
+ parentId: value[parentField] && value[parentField][0] || false,
+ });
+ });
+ Object.keys(category.values).forEach(function (valueId) {
+ var value = category.values[valueId];
+ if (value.parentId) {
+ category.values[value.parentId].childrenIds.push(value.id);
+ }
+ });
+ category.rootIds = Object.keys(category.values).filter(function (valueId) {
+ var value = category.values[valueId];
+ return value.parentId === false;
+ });
+
+ // set active value
+ var validValues = _.pluck(category.values, 'id').concat([false]);
+ // set active value from context
+ var value = this.defaultCategoryValues[category.fieldName];
+ // if not set in context, or set to an unknown value, set active value
+ // from localStorage
+ if (!_.contains(validValues, value)) {
+ var storageKey = this._getLocalStorageKey(category);
+ value = this.call('local_storage', 'getItem', storageKey);
+ }
+ // if not set in localStorage either, select 'All'
+ category.activeValueId = _.contains(validValues, value) ? value : false;
+
+ // unfold ancestor values of active value to make it is visible
+ if (category.activeValueId) {
+ var parentValueIds = this._getAncestorValueIds(category, category.activeValueId);
+ parentValueIds.forEach(function (parentValue) {
+ category.values[parentValue].folded = false;
+ });
+ }
+ },
+ /**
+ * @private
+ * @param {string} filterId
+ * @param {Object[]} values
+ */
+ _createFilterTree: function (filterId, values) {
+ var filter = this.filters[filterId];
+
+ // restore checked property
+ values.forEach(function (value) {
+ var oldValue = filter.values && filter.values[value.id];
+ value.checked = oldValue && oldValue.checked || false;
+ });
+
+ filter.values = {};
+ var groupIds = [];
+ if (filter.groupBy) {
+ var groups = {};
+ values.forEach(function (value) {
+ var groupId = value.group_id;
+ if (!groups[groupId]) {
+ if (groupId) {
+ groupIds.push(groupId);
+ }
+ groups[groupId] = {
+ folded: false,
+ id: groupId,
+ name: value.group_name,
+ values: {},
+ tooltip: value.group_tooltip,
+ sequence: value.group_sequence,
+ sortedValueIds: [],
+ };
+ // restore former checked and folded state
+ var oldGroup = filter.groups && filter.groups[groupId];
+ groups[groupId].state = oldGroup && oldGroup.state || false;
+ groups[groupId].folded = oldGroup && oldGroup.folded || false;
+ }
+ groups[groupId].values[value.id] = value;
+ groups[groupId].sortedValueIds.push(value.id);
+ });
+ filter.groups = groups;
+ filter.sortedGroupIds = _.sortBy(groupIds, function (groupId) {
+ return groups[groupId].sequence || groups[groupId].name;
+ });
+ Object.keys(filter.groups).forEach(function (groupId) {
+ filter.values = _.extend(filter.values, filter.groups[groupId].values);
+ });
+ } else {
+ values.forEach(function (value) {
+ filter.values[value.id] = value;
+ });
+ filter.sortedValueIds = values.map(function (value) {
+ return value.id;
+ });
+ }
+ },
+ /**
+ * Fetch values for each category. This is done only once, at startup.
+ *
+ * @private
+ * @returns {$.Promise} resolved when all categories have been fetched
+ */
+ _fetchCategories: function () {
+ var self = this;
+ var defs = Object.keys(this.categories).map(function (categoryId) {
+ var category = self.categories[categoryId];
+ var field = self.fields[category.fieldName];
+ var def;
+ if (field.type === 'selection') {
+ var values = field.selection.map(function (value) {
+ return {id: value[0], display_name: value[1]};
+ });
+ def = $.when(values);
+ } else {
+ def = self._rpc({
+ method: 'search_panel_select_range',
+ model: self.model,
+ args: [category.fieldName],
+ }).then(function (result) {
+ category.parentField = result.parent_field;
+ return result.values;
+ });
+ }
+ return def.then(function (values) {
+ self._createCategoryTree(categoryId, values);
+ });
+ });
+ return $.when.apply($, defs);
+ },
+ /**
+ * Fetch values for each filter. This is done at startup, and at each reload
+ * (when the controlPanel or searchPanel domain changes).
+ *
+ * @private
+ * @returns {$.Promise} resolved when all filters have been fetched
+ */
+ _fetchFilters: function () {
+ var self = this;
+ var evalContext = {};
+ Object.keys(this.categories).forEach(function (categoryId) {
+ var category = self.categories[categoryId];
+ evalContext[category.fieldName] = category.activeValueId;
+ });
+ var categoryDomain = this._getCategoryDomain();
+ var filterDomain = this._getFilterDomain();
+ var defs = Object.keys(this.filters).map(function (filterId) {
+ var filter = self.filters[filterId];
+ return self._rpc({
+ method: 'search_panel_select_multi_range',
+ model: self.model,
+ args: [filter.fieldName],
+ kwargs: {
+ category_domain: categoryDomain,
+ comodel_domain: Domain.prototype.stringToArray(filter.domain, evalContext),
+ disable_counters: filter.disableCounters,
+ filter_domain: filterDomain,
+ group_by: filter.groupBy || false,
+ search_domain: self.searchDomain,
+ },
+ }).then(function (values) {
+ self._createFilterTree(filterId, values);
+ });
+ });
+ return $.when.apply($, defs);
+ },
+ /**
+ * Compute and return the domain based on the current active categories.
+ *
+ * @private
+ * @returns {Array[]}
+ */
+ _getCategoryDomain: function () {
+ var self = this;
+ function categoryToDomain(domain, categoryId) {
+ var category = self.categories[categoryId];
+ if (category.activeValueId) {
+ domain.push([category.fieldName, '=', category.activeValueId]);
+ }
+ return domain;
+ }
+ return Object.keys(this.categories).reduce(categoryToDomain, []);
+ },
+ /**
+ * Compute and return the domain based on the current checked filters.
+ * The values of a single filter are combined using a simple rule: checked values within
+ * a same group are combined with an 'OR' (this is expressed as single condition using a list)
+ * and groups are combined with an 'AND' (expressed by concatenation of conditions).
+ * If a filter has no groups, its checked values are implicitely considered as forming
+ * a group (and grouped using an 'OR').
+ *
+ * @private
+ * @returns {Array[]}
+ */
+ _getFilterDomain: function () {
+ var self = this;
+ function getCheckedValueIds(values) {
+ return Object.keys(values).reduce(function (checkedValues, valueId) {
+ if (values[valueId].checked) {
+ checkedValues.push(values[valueId].id);
+ }
+ return checkedValues;
+ }, []);
+ }
+ function filterToDomain(domain, filterId) {
+ var filter = self.filters[filterId];
+ if (filter.groups) {
+ Object.keys(filter.groups).forEach(function (groupId) {
+ var group = filter.groups[groupId];
+ var checkedValues = getCheckedValueIds(group.values);
+ if (checkedValues.length) {
+ domain.push([filter.fieldName, 'in', checkedValues]);
+ }
+ });
+ } else if (filter.values) {
+ var checkedValues = getCheckedValueIds(filter.values);
+ if (checkedValues.length) {
+ domain.push([filter.fieldName, 'in', checkedValues]);
+ }
+ }
+ return domain;
+ }
+ return Object.keys(this.filters).reduce(filterToDomain, []);
+ },
+ /**
+ * The active id of each category is stored in the localStorage, s.t. it
+ * can be restored afterwards (when the action is reloaded, for instance).
+ * This function returns the key in the sessionStorage for a given category.
+ *
+ * @param {Object} category
+ * @returns {string}
+ */
+ _getLocalStorageKey: function (category) {
+ return 'searchpanel_' + this.model + '_' + category.fieldName;
+ },
+ /**
+ * @private
+ * @param {Object} category
+ * @param {integer} categoryValueId
+ * @returns {integer[]} list of ids of the ancestors of the given value in
+ * the given category
+ */
+ _getAncestorValueIds: function (category, categoryValueId) {
+ var categoryValue = category.values[categoryValueId];
+ var parentId = categoryValue.parentId;
+ if (parentId) {
+ return [parentId].concat(this._getAncestorValueIds(category, parentId));
+ }
+ return [];
+ },
+ /**
+ * @private
+ */
+ _render: function () {
+ var self = this;
+ this.$el.empty();
+
+ // sort categories and filters according to their index
+ var categories = Object.keys(this.categories).map(function (categoryId) {
+ return self.categories[categoryId];
+ });
+ var filters = Object.keys(this.filters).map(function (filterId) {
+ return self.filters[filterId];
+ });
+ var sections = categories.concat(filters).sort(function (s1, s2) {
+ return s1.index - s2.index;
+ });
+
+ sections.forEach(function (section) {
+ if (Object.keys(section.values).length) {
+ if (section.type === 'category') {
+ self.$el.append(self._renderCategory(section));
+ } else {
+ self.$el.append(self._renderFilter(section));
+ }
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {Object} category
+ * @returns {string}
+ */
+ _renderCategory: function (category) {
+ return qweb.render('SearchPanel.Category', {category: category});
+ },
+ /**
+ * @private
+ * @param {Object} filter
+ * @returns {jQuery}
+ */
+ _renderFilter: function (filter) {
+ var $filter = $(qweb.render('SearchPanel.Filter', {filter: filter}));
+
+ // set group inputs in indeterminate state when necessary
+ Object.keys(filter.groups || {}).forEach(function (groupId) {
+ var state = filter.groups[groupId].state;
+ // group 'false' is not displayed
+ if (groupId !== 'false' && state === 'indeterminate') {
+ $filter
+ .find('.o_search_panel_filter_group[data-group-id=' + groupId + '] input')
+ .get(0)
+ .indeterminate = true;
+ }
+ });
+
+ return $filter;
+ },
+ /**
+ * Compute the current searchPanel domain based on categories and filters,
+ * and notify environment of the domain change.
+ *
+ * Note that this assumes that the environment will update the searchPanel.
+ * This is done as such to ensure the coordination between the reloading of
+ * the searchPanel and the reloading of the data.
+ *
+ * @private
+ */
+ _notifyDomainUpdated: function () {
+ this.needReload = true;
+ this.trigger_up('search_panel_domain_updated', {
+ domain: this.getDomain(),
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onCategoryValueClicked: function (ev) {
+ ev.stopPropagation();
+ var $item = $(ev.currentTarget).closest('.o_search_panel_category_value');
+ var category = this.categories[$item.data('categoryId')];
+ var valueId = $item.data('id') || false;
+ category.activeValueId = valueId;
+ var storageKey = this._getLocalStorageKey(category);
+ this.call('local_storage', 'setItem', storageKey, valueId);
+ this._notifyDomainUpdated();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onFilterGroupChanged: function (ev) {
+ ev.stopPropagation();
+ var $item = $(ev.target).closest('.o_search_panel_filter_group');
+ var filter = this.filters[$item.data('filterId')];
+ var groupId = $item.data('groupId');
+ var group = filter.groups[groupId];
+ group.state = group.state === 'checked' ? 'unchecked' : 'checked';
+ Object.keys(group.values).forEach(function (valueId) {
+ group.values[valueId].checked = group.state === 'checked';
+ });
+ this._notifyDomainUpdated();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onFilterValueChanged: function (ev) {
+ ev.stopPropagation();
+ var $item = $(ev.target).closest('.o_search_panel_filter_value');
+ var valueId = $item.data('valueId');
+ var filter = this.filters[$item.data('filterId')];
+ var value = filter.values[valueId];
+ value.checked = !value.checked;
+ var group = filter.groups && filter.groups[value.group_id];
+ if (group) {
+ var valuePartition = _.partition(Object.keys(group.values), function (valueId) {
+ return group.values[valueId].checked;
+ });
+ if (valuePartition[0].length && valuePartition[1].length) {
+ group.state = 'indeterminate';
+ } else if (valuePartition[0].length) {
+ group.state = 'checked';
+ } else {
+ group.state = 'unchecked';
+ }
+ }
+ this._notifyDomainUpdated();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onToggleFoldCategory: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var $item = $(ev.currentTarget).closest('.o_search_panel_category_value');
+ var category = this.categories[$item.data('categoryId')];
+ var valueId = $item.data('id');
+ category.values[valueId].folded = !category.values[valueId].folded;
+ this._render();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onToggleFoldFilterGroup: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var $item = $(ev.currentTarget).closest('.o_search_panel_filter_group');
+ var filter = this.filters[$item.data('filterId')];
+ var groupId = $item.data('groupId');
+ filter.groups[groupId].folded = !filter.groups[groupId].folded;
+ this._render();
+ },
+});
+
+return SearchPanel;
+
+});
diff --git a/addons/web/static/src/scss/kanban_view.scss b/addons/web/static/src/scss/kanban_view.scss
index c4c0e9d011d..b9abdb14d48 100644
--- a/addons/web/static/src/scss/kanban_view.scss
+++ b/addons/web/static/src/scss/kanban_view.scss
@@ -1,4 +1,3 @@
-
// ------- Kanban View Layout -------
.o_kanban_view {
display: flex;
@@ -569,3 +568,98 @@
z-index: 1; // show the title over kanban color
}
}
+
+// ------- Kanban with SearchPanel -------
+$o-searchpanel-p: $o-horizontal-padding;
+$o-searchpanel-p-small: $o-horizontal-padding*0.5;
+$o-searchpanel-p-tiny: $o-searchpanel-p-small*0.5;
+
+$o-searchpanel-category-default-color: $o-brand-primary;
+$o-searchpanel-filter-default-color: #D59244;
+
+.o_kanban_with_searchpanel {
+ display: flex;
+ align-items: flex-start;
+
+ .o_kanban_view {
+ flex: 1 1 100%;
+ overflow: auto; // make the kanban renderer and search panel scroll individually
+ max-height: 100%;
+ }
+ .o_search_panel {
+ flex: 0 0 220px;
+ overflow: auto;
+ height: 100%;
+ padding: $o-searchpanel-p-small $o-searchpanel-p-small $o-searchpanel-p*2 $o-searchpanel-p;
+ border-right: 1px solid $gray-300;
+ background-color: white;
+
+ .o_search_panel_category .o_search_panel_section_icon {
+ color: $o-brand-odoo;
+ }
+ .o_search_panel_filter .o_search_panel_section_icon {
+ color: $o-searchpanel-filter-default-color;
+ }
+
+ .o_search_panel_label {
+ cursor: pointer;
+ user-select: none;
+
+ .o_toggle_fold {
+ padding: 3px;
+ }
+ }
+ .o_search_panel_section_header {
+ padding: $o-searchpanel-p-small 0;
+ }
+ .list-group-item {
+ padding: 0 0 $o-searchpanel-p-small 0;
+
+ .list-group-item {
+ padding: 0 0 0 $custom-control-gutter;
+ margin-bottom: $o-searchpanel-p-tiny*0.5;
+ &:first-child {
+ margin-top: $o-searchpanel-p-tiny*0.5;
+ }
+ }
+ span.o_search_panel_label_title {
+ color: $headings-color;
+ @include o-text-overflow(inline-block, calc(100% - 22px));
+ }
+ header.active {
+ background-color: $list-group-action-active-bg;
+ }
+ }
+ .o_search_panel_category_value {
+ header {
+ margin-left: -$o-searchpanel-p-tiny;
+ padding-left: $o-searchpanel-p-tiny;
+ }
+ .o_search_panel_category_value {
+ position: relative;
+ padding-left: $o-searchpanel-p;
+ padding-bottom: $o-searchpanel-p-tiny;
+ margin-bottom: 0;
+
+ &:before, &:after {
+ @include o-position-absolute(0, $left: $o-searchpanel-p-tiny)
+ @include size(1px, 100%);
+ background: $gray-500;
+ content: '';
+ }
+ &:after {
+ top: 10px;
+ @include size(8px, 1px);
+ }
+ &:last-child {
+ &:before {
+ height: 11px;
+ }
+ &:after {
+ top: 11px;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/addons/web/static/src/xml/kanban.xml b/addons/web/static/src/xml/kanban.xml
index 06ba6b71eb6..8a9ac5fdb7d 100644
--- a/addons/web/static/src/xml/kanban.xml
+++ b/addons/web/static/src/xml/kanban.xml
@@ -192,6 +192,110 @@
</div>
</t>
+<!-- Kanban SearchPanel -->
+<t t-name="SearchPanel.SectionHeader">
+ <header class="o_search_panel_section_header text-uppercase">
+ <i t-attf-class="mr-1 fa #{section.icon} o_search_panel_section_icon" t-attf-style="#{section.color ? ('color: ' + section.color) : ''}"/>
+ <b><t t-esc="section.description"/></b>
+ </header>
+</t>
+
+<t t-name="SearchPanel.Category">
+ <section class="o_search_panel_section o_search_panel_category">
+ <t t-call="SearchPanel.SectionHeader">
+ <t t-set="section" t-value="category"/>
+ </t>
+ <ul class="list-group d-block">
+ <li class="o_search_panel_category_value border-0 list-group-item" t-att-data-category-id="category.id">
+ <header t-att-class="'list-group-item-action' + (!category.activeValueId ? ' active' : '')">
+ <label class="o_search_panel_label mb0 d-block">
+ <span class="o_search_panel_label_title"><b>All</b></span>
+ </label>
+ </header>
+ </li>
+ <t t-call="SearchPanel.CategoryValues">
+ <t t-set="values" t-value="category.rootIds"/>
+ </t>
+ </ul>
+ </section>
+</t>
+<t t-name="SearchPanel.CategoryValues">
+ <t t-foreach="values" t-as="valueId">
+ <t t-set="value" t-value="category.values[valueId]"/>
+ <li class="o_search_panel_category_value border-0 list-group-item" t-att-data-id="value.id" t-att-data-category-id="category.id">
+ <header t-att-class="'list-group-item-action' + (value.id === category.activeValueId ? ' active' : '')">
+ <label t-att-for="value.display_name" class="o_search_panel_label mb0 d-block">
+ <i t-if="value.childrenIds.length" t-att-class="'fa fa-fw pull-right o_toggle_fold ' + (value.folded ? 'fa-caret-left' : 'fa-caret-down')"/>
+ <span class="o_search_panel_label_title"><t t-esc="value.display_name"/></span>
+ </label>
+ </header>
+ <ul t-if="!value.folded" class="list-group d-block">
+ <t t-call="SearchPanel.CategoryValues">
+ <t t-set="values" t-value="value.childrenIds"/>
+ </t>
+ </ul>
+ </li>
+ </t>
+</t>
+
+<t t-name="SearchPanel.Filter">
+ <section class="o_search_panel_section o_search_panel_filter">
+ <t t-call="SearchPanel.SectionHeader">
+ <t t-set="section" t-value="filter"/>
+ </t>
+ <ul class="list-group d-block">
+ <t t-if="filter.groups" t-call="SearchPanel.FilterGroups">
+ <t t-set="groups" t-value="filter.groups"/>
+ </t>
+ <t t-else="" t-call="SearchPanel.FilterValues">
+ <t t-set="sortedValueIds" t-value="filter.sortedValueIds"/>
+ <t t-set="values" t-value="filter.values"/>
+ </t>
+ </ul>
+ </section>
+</t>
+<t t-name="SearchPanel.FilterGroups">
+ <li t-foreach="filter.sortedGroupIds" t-as="groupId" t-att-data-group-id="groupId" t-att-data-filter-id="filter.id"
+ class="o_search_panel_filter_group list-group-item border-0">
+ <t t-set="group" t-value="groups[groupId]"/>
+ <div class="custom-control custom-checkbox">
+ <t t-set="inputId" t-value="_.uniqueId('input_')"/>
+ <input type="checkbox" t-att-id="inputId" t-att-checked="group.state === 'checked' ? 'checked' : undefined" class="custom-control-input"/>
+ <label t-att-for="inputId" class="o_search_panel_label custom-control-label d-block" t-att-title="group.tooltip">
+ <i t-att-class="'fa fa-fw pull-right o_toggle_fold ' + (group.folded ? 'fa-caret-left' : 'fa-caret-down')"/>
+ <span class="o_search_panel_label_title"><t t-esc="group.name"/></span>
+ </label>
+ </div>
+ <ul t-if="!group.folded" class="list-group d-block">
+ <t t-call="SearchPanel.FilterValues">
+ <t t-set="sortedValueIds" t-value="group.sortedValueIds"/>
+ <t t-set="values" t-value="group.values"/>
+ </t>
+ </ul>
+ </li>
+ <ul t-if="groups['false']" class="list-group d-block">
+ <t t-call="SearchPanel.FilterValues">
+ <t t-set="group" t-value="groups['false']"/>
+ <t t-set="sortedValueIds" t-value="group.sortedValueIds"/>
+ <t t-set="values" t-value="group.values"/>
+ </t>
+ </ul>
+</t>
+<t t-name="SearchPanel.FilterValues">
+ <li t-foreach="sortedValueIds" t-as="valueId" t-att-data-value-id="valueId" t-att-data-filter-id="filter.id"
+ class="o_search_panel_filter_value list-group-item border-0">
+ <t t-set="value" t-value="values[valueId]"/>
+ <div class="custom-control custom-checkbox">
+ <t t-set="inputId" t-value="_.uniqueId('input_')"/>
+ <input type="checkbox" t-att-id="inputId" t-att-checked="value.checked ? 'checked' : undefined" class="custom-control-input"/>
+ <label t-att-for="inputId" class="o_search_panel_label custom-control-label d-block" t-att-title="group &amp;&amp; group.tooltip">
+ <span class="o_search_panel_label_title"><t t-esc="value.name"/></span>
+ <span t-if="value.count > 0" class="pull-right text-muted mr-2 mt-1 small"><t t-esc="value.count"/></span>
+ <span t-if="filter.disableCounters" class="pull-right text-muted mr-2 mt-1 small">?</span>
+ </label>
+ </div>
+ </li>
+</t>
<!-- Mobile templates -->
<t t-name="KanbanView.MobileTabs">
@@ -203,5 +307,4 @@
</t>
</div>
</t>
-
</template>
diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js
index 85d3a8b8119..09f6852951b 100644
--- a/addons/web/static/tests/helpers/mock_server.js
+++ b/addons/web/static/tests/helpers/mock_server.js
@@ -471,6 +471,119 @@ var MockServer = Class.extend({
return modelFields;
},
/**
+ * Simulate a call to the 'search_panel_select_range' method.
+ *
+ * Note that the implementation assumes that 'parent_id' is the field that
+ * encodes the parent relationship.
+ *
+ * @private
+ * @param {string} model
+ * @param {Array} args
+ * @returns {Object}
+ */
+ _mockSearchPanelSelectRange: function (model, args) {
+ var fieldName = args[0];
+ var field = this.data[model].fields[fieldName];
+
+ if (field.type !== 'many2one') {
+ throw new Error('Only fields of type many2one are handled');
+ }
+
+ var fields = ['display_name'];
+ var parentField = this.data[field.relation].fields.parent_id;
+ if (parentField) {
+ fields.push('parent_id');
+ }
+ return {
+ parent_field: parentField ? 'parent_id' : false,
+ values: this._mockSearchRead(field.relation, [[], fields], {}),
+ };
+ },
+ /**
+ * Simulate a call to the 'search_panel_select_multi_range' method.
+ *
+ * Note that only the many2one and selection cases are handled by this
+ * function.
+ *
+ * @param {string} model
+ * @param {Array} args
+ * @param {Object} kwargs
+ * @returns {Object}
+ */
+ _mockSearchPanelSelectMultiRange: function (model, args, kwargs) {
+ var fieldName = args[0];
+ var field = this.data[model].fields[fieldName];
+ var comodelDomain = kwargs.comodel_domain || [];
+
+ if (!_.contains(['many2one', 'selection'], field.type)) {
+ throw new Error('Only fields of type many2one and selection are handled');
+ }
+
+ var modelDomain;
+ var disableCounters = kwargs.disable_counters || false;
+ modelDomain = [[fieldName, '!=', false]]
+ .concat(kwargs.category_domain)
+ .concat(kwargs.filter_domain)
+ .concat(kwargs.search_domain);
+ var groupBy = kwargs.group_by || false;
+ var comodel = field.relation || false;
+ var groupByField = groupBy && this.data[comodel].fields[groupBy];
+
+ // get counters
+ var groups;
+ var counters = {};
+ if (!disableCounters) {
+ groups = this._mockReadGroup(model, {
+ domain: modelDomain,
+ fields: [fieldName],
+ groupby: [fieldName],
+ });
+ groups.forEach(function (group) {
+ var groupId = field.type === 'many2one' ? group[fieldName][0] : group[fieldName];
+ counters[groupId] = group[fieldName + '_count'];
+ });
+ }
+
+ // get filter values
+ var filterValues = [];
+ if (field.type === 'many2one') {
+ var fields = groupBy ? ['display_name', groupBy] : ['display_name'];
+ var records = this._mockSearchRead(comodel, [comodelDomain, fields], {});
+ records.forEach(function (record) {
+ var filterValue = {
+ count: counters[record.id] || 0,
+ id: record.id,
+ name: record.display_name,
+ };
+ if (groupBy) {
+ var id = record[groupBy];
+ var name = record[groupBy];
+ if (groupByField.type === 'many2one') {
+ name = id[1];
+ id = id[0];
+ } else if (groupByField.type === 'selection') {
+ name = _.find(field.selection, function (option) {
+ return option[0] === id;
+ })[1];
+ }
+ filterValue.group_id = id;
+ filterValue.group_name = name;
+ }
+ filterValues.push(filterValue);
+ });
+ } else if (field.type === 'selection') {
+ field.selection.forEach(function (option) {
+ filterValues.push({
+ count: counters[option[0]] || 0,
+ id: option[0],
+ name: option[1],
+ });
+ });
+ }
+
+ return filterValues;
+ },
+ /**
* Simulate a call to the '/web/action/load' route
*
* @private
@@ -1100,6 +1213,12 @@ var MockServer = Class.extend({
case 'fields_get':
return $.when(this._mockFieldsGet(args.model, args.args));
+ case 'search_panel_select_range':
+ return $.when(this._mockSearchPanelSelectRange(args.model, args.args, args.kwargs));
+
+ case 'search_panel_select_multi_range':
+ return $.when(this._mockSearchPanelSelectMultiRange(args.model, args.args, args.kwargs));
+
case 'load_views':
return $.when(this._mockLoadViews(args.model, args.kwargs));
diff --git a/addons/web/static/tests/views/kanban_mobile_tests.js b/addons/web/static/tests/views/kanban_mobile_tests.js
index f20c4296a42..a564ff7f8ed 100644
--- a/addons/web/static/tests/views/kanban_mobile_tests.js
+++ b/addons/web/static/tests/views/kanban_mobile_tests.js
@@ -174,5 +174,34 @@ QUnit.module('Views', {
kanban.destroy();
});
+
+ QUnit.test('kanban with searchpanel: there should be no searchpanel in mobile', function (assert) {
+ assert.expect(3);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="product_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsNone(kanban, '.o_search_panel');
+ assert.verifySteps(['/web/dataset/search_read']);
+
+ kanban.destroy();
+ });
});
});
diff --git a/addons/web/static/tests/views/search_panel_tests.js b/addons/web/static/tests/views/search_panel_tests.js
new file mode 100644
index 00000000000..4f9e20f70ac
--- /dev/null
+++ b/addons/web/static/tests/views/search_panel_tests.js
@@ -0,0 +1,1448 @@
+odoo.define('web.search_panel_tests', function (require) {
+"use strict";
+
+var AbstractStorageService = require('web.AbstractStorageService');
+var KanbanView = require('web.KanbanView');
+var RamStorage = require('web.RamStorage');
+var testUtils = require('web.test_utils');
+
+var createView = testUtils.createView;
+
+QUnit.module('Views', {
+ beforeEach: function () {
+ this.data = {
+ partner: {
+ fields: {
+ foo: {string: "Foo", type: 'char'},
+ bar: {string: "Bar", type: 'boolean'},
+ company_id: {string: "company", type: 'many2one', relation: 'company'},
+ category_id: { string: "category", type: 'many2one', relation: 'category' },
+ state: { string: "State", type: 'selection', selection: [['abc', "ABC"], ['def', "DEF"], ['ghi', "GHI"]]},
+ },
+ records: [
+ {id: 1, bar: true, foo: "yop", company_id: 3, state: 'abc', category_id: 6},
+ {id: 2, bar: true, foo: "blip", company_id: 5, state: 'def', category_id: 7},
+ {id: 3, bar: true, foo: "gnap", company_id: 3, state: 'ghi', category_id: 7},
+ {id: 4, bar: false, foo: "blip", company_id: 5, state: 'ghi', category_id: 7},
+ ]
+ },
+ company: {
+ fields: {
+ name: {string: "Display Name", type: 'char'},
+ parent_id: {string: 'Parent company', type: 'many2one', relation: 'company'},
+ category_id: {string: 'Category', type: 'many2one', relation: 'category'},
+ },
+ records: [
+ {id: 3, name: "asustek", category_id: 6},
+ {id: 5, name: "agrolait", category_id: 7},
+ ],
+ },
+ category: {
+ fields: {
+ name: {string: "Category Name", type: 'char'},
+ },
+ records: [
+ {id: 6, name: "gold"},
+ {id: 7, name: "silver"},
+ ]
+ },
+ };
+
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new RamStorage(),
+ });
+ this.services = {
+ local_storage: RamStorageService,
+ };
+ },
+}, function () {
+
+ QUnit.module('SearchPanel in Kanban views');
+
+ QUnit.test('basic rendering', function (assert) {
+ assert.expect(17);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.step((args.method || route) + (args.model ? (' on ' + args.model) : ''));
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="company_id"/>' +
+ '<field select="multi" name="category_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ });
+
+ assert.containsOnce(kanban, '.o_content.o_kanban_with_searchpanel > .o_search_panel');
+ assert.containsOnce(kanban, '.o_content.o_kanban_with_searchpanel > .o_kanban_view');
+
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ assert.containsN(kanban, '.o_search_panel_section', 2);
+
+ var $firstSection = kanban.$('.o_search_panel_section:first');
+ assert.hasClass($firstSection.find('.o_search_panel_section_header i'), 'fa-folder');
+ assert.containsOnce($firstSection, '.o_search_panel_section_header:contains(company)');
+ assert.containsN($firstSection, '.o_search_panel_category_value', 3);
+ assert.containsOnce($firstSection, '.o_search_panel_category_value:first .active');
+ assert.strictEqual($firstSection.find('.o_search_panel_category_value').text().replace(/\s/g, ''),
+ 'Allasustekagrolait');
+
+ var $secondSection = kanban.$('.o_search_panel_section:nth(1)');
+ assert.hasClass($secondSection.find('.o_search_panel_section_header i'), 'fa-filter');
+ assert.containsOnce($secondSection, '.o_search_panel_section_header:contains(category)');
+ assert.containsN($secondSection, '.o_search_panel_filter_value', 2);
+ assert.strictEqual($secondSection.find('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'gold1silver3');
+
+ assert.verifySteps([
+ 'search_panel_select_range on partner',
+ 'search_panel_select_multi_range on partner',
+ '/web/dataset/search_read on partner',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('sections with custom icon and color', function (assert) {
+ assert.expect(4);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="company_id" icon="fa-car" color="blue"/>' +
+ '<field select="multi" name="state" icon="fa-star" color="#000"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ });
+
+ assert.hasClass(kanban.$('.o_search_panel_section_header:first i'), 'fa-car');
+ assert.hasAttrValue(kanban.$('.o_search_panel_section_header:first i'), 'style="{color: blue}"');
+ assert.hasClass(kanban.$('.o_search_panel_section_header:nth(1) i'), 'fa-star');
+ assert.hasAttrValue(kanban.$('.o_search_panel_section_header:nth(1) i'), 'style="{color: #000}"');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('sections with attr invisible="1" are ignored', function (assert) {
+ // 'groups' attributes are converted server-side into invisible="1" when the user doesn't
+ // belong to the given group
+ assert.expect(4);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="company_id"/>' +
+ '<field select="multi" invisible="1" name="state"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ assert.containsOnce(kanban, '.o_search_panel_section');
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ '/web/dataset/search_read',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('categories and filters order is kept', function (assert) {
+ assert.expect(4);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="company_id"/>' +
+ '<field select="multi" name="category_id"/>' +
+ '<field name="state"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ });
+
+ assert.containsN(kanban, '.o_search_panel_section', 3);
+ assert.strictEqual(kanban.$('.o_search_panel_section_header:nth(0)').text().trim(),
+ 'company');
+ assert.strictEqual(kanban.$('.o_search_panel_section_header:nth(1)').text().trim(),
+ 'category');
+ assert.strictEqual(kanban.$('.o_search_panel_section_header:nth(2)').text().trim(),
+ 'State');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('specify active category value in context', function (assert) {
+ assert.expect(1);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="company_id"/>' +
+ '<field name="state"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.deepEqual(args.domain, [["state", "=", "ghi"]]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ context: {
+ searchpanel_default_company_id: false,
+ searchpanel_default_state: 'ghi',
+ },
+ });
+
+ kanban.destroy();
+ });
+
+ QUnit.test('use category (on many2one) to refine search', function (assert) {
+ assert.expect(14);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="company_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ domain: [['bar', '=', true]],
+ });
+
+ // select 'asustek'
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // select 'agrolait'
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // select 'All'
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:first header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ assert.verifySteps([
+ '[["bar","=",true]]',
+ '[["bar","=",true],["company_id","=",3]]',
+ '[["bar","=",true],["company_id","=",5]]',
+ '[["bar","=",true]]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('use category (on selection) to refine search', function (assert) {
+ assert.expect(14);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="state"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ });
+
+ // select 'abc'
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // select 'ghi'
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(3) header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(3) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // select 'All' again
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:first header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ assert.verifySteps([
+ '[]',
+ '[["state","=","abc"]]',
+ '[["state","=","ghi"]]',
+ '[]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('store and retrieve active category value', function (assert) {
+ assert.expect(8);
+
+ var Storage = RamStorage.extend({
+ getItem: function (key) {
+ assert.step('getItem ' + key);
+ return 3; // 'asustek'
+ },
+ setItem: function (key, value) {
+ assert.step('setItem ' + key + ' to ' + value);
+ },
+ });
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new Storage(),
+ });
+
+ var expectedActiveId = 3;
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ services: {
+ local_storage: RamStorageService,
+ },
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="company_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.deepEqual(args.domain, [['company_id', '=', expectedActiveId]]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // 'asustek' should be selected by default
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // select 'agrolait'
+ expectedActiveId = 5;
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) header'));
+
+ assert.verifySteps([
+ 'getItem searchpanel_partner_company_id',
+ 'setItem searchpanel_partner_company_id to 5',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('retrieved category value does not exist', function (assert) {
+ assert.expect(5);
+
+ var Storage = RamStorage.extend({
+ getItem: function (key) {
+ assert.step('getItem ' + key);
+ return 343; // this value doesn't exist
+ },
+ });
+ var RamStorageService = AbstractStorageService.extend({
+ storage: new Storage(),
+ });
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ services: {
+ local_storage: RamStorageService,
+ },
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="company_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.deepEqual(args.domain, []);
+ }
+ return this._super.apply(this, arguments);
+ },
+ });
+
+ // 'All' should be selected by default as the retrieved value does not exist
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('use two categories to refine search', function (assert) {
+ assert.expect(14);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="company_id"/>' +
+ '<field name="state"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ domain: [['bar', '=', true]],
+ });
+
+ assert.containsN(kanban, '.o_search_panel_section', 2);
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // select 'asustek'
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value[data-id=3] header'));
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // select 'abc'
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value[data-id=abc] header'));
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // select 'ghi'
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value[data-id=ghi] header'));
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // select 'All' in first category (company_id)
+ testUtils.dom.click(kanban.$('.o_search_panel_section:first .o_search_panel_category_value:first header'));
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // select 'All' in second category (state)
+ testUtils.dom.click(kanban.$('.o_search_panel_section:nth(1) .o_search_panel_category_value:first header'));
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ assert.verifySteps([
+ '[["bar","=",true]]',
+ '[["bar","=",true],["company_id","=",3]]',
+ '[["bar","=",true],["company_id","=",3],["state","=","abc"]]',
+ '[["bar","=",true],["company_id","=",3],["state","=","ghi"]]',
+ '[["bar","=",true],["state","=","ghi"]]',
+ '[["bar","=",true]]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('category with parent_field', function (assert) {
+ assert.expect(24);
+
+ this.data.company.records.push({id: 40, name: 'child company 1', parent_id: 5});
+ this.data.company.records.push({id: 41, name: 'child company 2', parent_id: 5});
+ this.data.partner.records[1].company_id = 40;
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="company_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ });
+
+ // 'All' is selected by default
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+ assert.containsN(kanban, '.o_search_panel_category_value', 3);
+ assert.containsOnce(kanban, '.o_search_panel_category_value .o_toggle_fold');
+
+ // unfold parent category
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value .o_toggle_fold'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+ assert.containsN(kanban, '.o_search_panel_category_value', 5);
+ assert.containsN(kanban, '.o_search_panel_category_value .o_search_panel_category_value', 2);
+
+ // click on first child company
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value .o_search_panel_category_value:first header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value .o_search_panel_category_value:first .active');
+ assert.containsOnce(kanban, '.o_kanban_record:not(.o_kanban_ghost)');
+
+ // click on parent company
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) > header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // fold category with children
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value .o_toggle_fold'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsN(kanban, '.o_search_panel_category_value', 3);
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ assert.verifySteps([
+ '[]',
+ '[["company_id","=",40]]',
+ '[["company_id","=",5]]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('can (un)fold parent category values', function (assert) {
+ assert.expect(7);
+
+ this.data.company.records.push({id: 40, name: 'child company 1', parent_id: 5});
+ this.data.company.records.push({id: 41, name: 'child company 2', parent_id: 5});
+ this.data.partner.records[1].company_id = 40;
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="company_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ });
+
+ assert.strictEqual(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold').length, 1,
+ "'agrolait' should be displayed as a parent category value");
+ assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold'), 'fa-caret-left',
+ "'agrolait' should be folded");
+ assert.containsN(kanban, '.o_search_panel_category_value', 3);
+
+ // unfold agrolait
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold'));
+ assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold'), 'fa-caret-down',
+ "'agrolait' should be open");
+ assert.containsN(kanban, '.o_search_panel_category_value', 5);
+
+ // fold agrolait
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold'));
+ assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold'), 'fa-caret-left',
+ "'agrolait' should be folded");
+ assert.containsN(kanban, '.o_search_panel_category_value', 3);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('fold status is kept at reload', function (assert) {
+ assert.expect(4);
+
+ this.data.company.records.push({id: 40, name: 'child company 1', parent_id: 5});
+ this.data.company.records.push({id: 41, name: 'child company 2', parent_id: 5});
+ this.data.partner.records[1].company_id = 40;
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="company_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ });
+
+ // unfold agrolait
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold'));
+ assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold'), 'fa-caret-down',
+ "'agrolait' should be open");
+ assert.containsN(kanban, '.o_search_panel_category_value', 5);
+
+ kanban.reload({});
+
+ assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold'), 'fa-caret-down',
+ "'agrolait' should be open");
+ assert.containsN(kanban, '.o_search_panel_category_value', 5);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('concurrency: delayed search_reads', function (assert) {
+ assert.expect(19);
+
+ var def;
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="company_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ domain: [['bar', '=', true]],
+ });
+
+ // 'All' should be selected by default
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // select 'asustek' (delay the reload)
+ def = $.Deferred();
+ var asustekDef = def;
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ // 'asustek' should be selected, but there should still be 3 records
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // select 'agrolait' (delay the reload)
+ def = $.Deferred();
+ var agrolaitDef = def;
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) header'));
+
+ // 'agrolait' should be selected, but there should still be 3 records
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // unlock asustek search (should be ignored, so there should still be 3 records)
+ asustekDef.resolve();
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // unlock agrolait search, there should now be 1 record
+ agrolaitDef.resolve();
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ assert.verifySteps([
+ '[["bar","=",true]]',
+ '[["bar","=",true],["company_id","=",3]]',
+ '[["bar","=",true],["company_id","=",5]]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('concurrency: misordered get_filters', function (assert) {
+ assert.expect(15);
+
+ var def;
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'search_panel_select_multi_range') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="state"/>' +
+ '<field select="multi" name="company_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ });
+
+ // 'All' should be selected by default
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ // select 'abc' (delay the reload)
+ def = $.Deferred();
+ var abcDef = def;
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ // 'All' should still be selected, and there should still be 4 records
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ // select 'ghi' (delay the reload)
+ def = $.Deferred();
+ var ghiDef = def;
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(3) header'));
+
+ // 'All' should still be selected, and there should still be 4 records
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ // unlock ghi search
+ ghiDef.resolve();
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(3) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // unlock abc search (should be ignored)
+ abcDef.resolve();
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(3) .active');
+ assert.containsN(kanban, '.o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('concurrency: delayed get_filter', function (assert) {
+ assert.expect(3);
+
+ var def;
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'search_panel_select_multi_range') {
+ return $.when(def).then(_.constant(result));
+ }
+ return result;
+ },
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field select="multi" name="company_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ });
+
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ // trigger a reload and delay the get_filter
+ def = $.Deferred();
+ kanban.reload({domain: [['id', '=', 1]]});
+
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 4);
+
+ def.resolve();
+
+ assert.containsOnce(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('use filter (on many2one) to refine search', function (assert) {
+ assert.expect(32);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'search_panel_select_multi_range') {
+ // the following keys should have same value for all calls to this route
+ var keys = ['field_name', 'group_by', 'comodel_domain', 'search_domain', 'category_domain'];
+ assert.deepEqual(_.pick(args.kwargs, keys), {
+ group_by: false,
+ comodel_domain: [],
+ search_domain: [['bar', '=', true]],
+ category_domain: [],
+ });
+ // the filter_domain depends on the filter selection
+ assert.step(JSON.stringify(args.kwargs.filter_domain));
+ }
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return result;
+ },
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field select="multi" name="company_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ domain: [['bar', '=', true]],
+ });
+
+ assert.containsN(kanban, '.o_search_panel_filter_value', 2);
+ assert.containsNone(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolait1');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // check 'asustek'
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input'));
+
+ assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolait');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // check 'agrolait'
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input'));
+
+ assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 2);
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolait1');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // uncheck 'asustek'
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input'));
+
+ assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustekagrolait1');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // uncheck 'agrolait'
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input'));
+
+ assert.containsNone(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolait1');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ assert.verifySteps([
+ // nothing checked
+ '[]',
+ '[["bar","=",true]]',
+ // 'asustek' checked
+ '[["bar","=",true],["company_id","in",[3]]]',
+ '[["company_id","in",[3]]]',
+ // 'asustek' and 'agrolait' checked
+ '[["bar","=",true],["company_id","in",[3,5]]]',
+ '[["company_id","in",[3,5]]]',
+ // 'agrolait' checked
+ '[["bar","=",true],["company_id","in",[5]]]',
+ '[["company_id","in",[5]]]',
+ // nothing checked
+ '[["bar","=",true]]',
+ '[]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('use filter (on selection) to refine search', function (assert) {
+ assert.expect(32);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'search_panel_select_multi_range') {
+ // the following keys should have same value for all calls to this route
+ var keys = ['group_by', 'comodel_domain', 'search_domain', 'category_domain'];
+ assert.deepEqual(_.pick(args.kwargs, keys), {
+ group_by: false,
+ comodel_domain: [],
+ search_domain: [['bar', '=', true]],
+ category_domain: [],
+ });
+ // the filter_domain depends on the filter selection
+ assert.step(JSON.stringify(args.kwargs.filter_domain));
+ }
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return result;
+ },
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field select="multi" name="state"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ domain: [['bar', '=', true]],
+ });
+
+ assert.containsN(kanban, '.o_search_panel_filter_value', 3);
+ assert.containsNone(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'ABC1DEF1GHI1');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // check 'abc'
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input'));
+
+ assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'ABC1DEFGHI');
+ assert.containsOnce(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 1);
+
+ // check 'def'
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input'));
+
+ assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 2);
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'ABC1DEF1GHI');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // uncheck 'abc'
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input'));
+
+ assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'ABCDEF1GHI');
+ assert.containsOnce(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)');
+
+ // uncheck 'def'
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input'));
+
+ assert.containsNone(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'ABC1DEF1GHI1');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ assert.verifySteps([
+ // nothing checked
+ '[]',
+ '[["bar","=",true]]',
+ // 'asustek' checked
+ '[["bar","=",true],["state","in",["abc"]]]',
+ '[["state","in",["abc"]]]',
+ // 'asustek' and 'agrolait' checked
+ '[["bar","=",true],["state","in",["abc","def"]]]',
+ '[["state","in",["abc","def"]]]',
+ // 'agrolait' checked
+ '[["bar","=",true],["state","in",["def"]]]',
+ '[["state","in",["def"]]]',
+ // nothing checked
+ '[["bar","=",true]]',
+ '[]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('only reload filters when domains change', function (assert) {
+ assert.expect(11);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="state"/>' +
+ '<field select="multi" name="company_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ viewOptions: {
+ limit: 2,
+ },
+ });
+
+ assert.verifySteps([
+ 'search_panel_select_multi_range',
+ '/web/dataset/search_read',
+ ]);
+
+ // go to page 2 (the domain doesn't change, so the filters should not be reloaded)
+ testUtils.dom.click(kanban.$('.o_pager_next'));
+
+ assert.verifySteps([
+ 'search_panel_select_multi_range',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ ]);
+
+ // reload with another domain, so the filters should be reloaded
+ kanban.reload({domain: [['id', '<', 5]]});
+
+ assert.verifySteps([
+ 'search_panel_select_multi_range',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'search_panel_select_multi_range',
+ ]);
+
+ // change category value, so the filters should be reloaded
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.verifySteps([
+ 'search_panel_select_multi_range',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ '/web/dataset/search_read',
+ 'search_panel_select_multi_range',
+ '/web/dataset/search_read',
+ 'search_panel_select_multi_range',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('filter with groupby', function (assert) {
+ assert.expect(42);
+
+ this.data.company.records.push({id: 11, name: 'camptocamp', category_id: 7});
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'search_panel_select_multi_range') {
+ // the following keys should have same value for all calls to this route
+ var keys = ['group_by', 'comodel_domain', 'search_domain', 'category_domain'];
+ assert.deepEqual(_.pick(args.kwargs, keys), {
+ group_by: 'category_id',
+ comodel_domain: [],
+ search_domain: [['bar', '=', true]],
+ category_domain: [],
+ });
+ // the filter_domain depends on the filter selection
+ assert.step(JSON.stringify(args.kwargs.filter_domain));
+ }
+ if (route === '/web/dataset/search_read') {
+ assert.step(JSON.stringify(args.domain));
+ }
+ return result;
+ },
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field select="multi" name="company_id" groupby="category_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ domain: [['bar', '=', true]],
+ });
+
+ assert.containsN(kanban, '.o_search_panel_filter_group', 2);
+ assert.containsOnce(kanban, '.o_search_panel_filter_group:first .o_search_panel_filter_value');
+ assert.containsN(kanban, '.o_search_panel_filter_group:nth(1) .o_search_panel_filter_value', 2);
+ assert.containsNone(kanban, '.o_search_panel_filter_value input:checked');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_group > div > label').text().replace(/\s/g, ''),
+ 'goldsilver');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolait1camptocamp');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 3);
+
+ // check 'asustek'
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_value:first input'));
+
+ assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
+ var firstGroupCheckbox = kanban.$('.o_search_panel_filter_group:first > div > input').get(0);
+ assert.strictEqual(firstGroupCheckbox.checked, true,
+ "first group checkbox should be checked");
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolaitcamptocamp');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ // check 'agrolait'
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input'));
+
+ assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 2);
+ var secondGroupCheckbox = kanban.$('.o_search_panel_filter_group:nth(1) > div > input').get(0);
+ assert.strictEqual(secondGroupCheckbox.checked, false,
+ "second group checkbox should not be checked");
+ assert.strictEqual(secondGroupCheckbox.indeterminate, true,
+ "second group checkbox should be indeterminate");
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustekagrolaitcamptocamp');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 0);
+
+ // check 'camptocamp'
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(2) input'));
+
+ assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 3);
+ secondGroupCheckbox = kanban.$('.o_search_panel_filter_group:nth(1) > div > input').get(0);
+ assert.strictEqual(secondGroupCheckbox.checked, true,
+ "second group checkbox should be checked");
+ assert.strictEqual(secondGroupCheckbox.indeterminate, false,
+ "second group checkbox should not be indeterminate");
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustekagrolaitcamptocamp');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 0);
+
+ // uncheck second group
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_group:nth(1) > div > input'));
+
+ assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
+ secondGroupCheckbox = kanban.$('.o_search_panel_filter_group:nth(1) > div > input').get(0);
+ assert.strictEqual(secondGroupCheckbox.checked, false,
+ "second group checkbox should not be checked");
+ assert.strictEqual(secondGroupCheckbox.indeterminate, false,
+ "second group checkbox should not be indeterminate");
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolaitcamptocamp');
+ assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2);
+
+ assert.verifySteps([
+ // nothing checked
+ '[]',
+ '[["bar","=",true]]',
+ // 'asustek' checked
+ '[["bar","=",true],["company_id","in",[3]]]',
+ '[["company_id","in",[3]]]',
+ // 'asustek' and 'agrolait' checked
+ '[["bar","=",true],["company_id","in",[3]],["company_id","in",[5]]]',
+ '[["company_id","in",[3]],["company_id","in",[5]]]',
+ // 'asustek', 'agrolait' and 'camptocamp' checked
+ '[["bar","=",true],["company_id","in",[3]],["company_id","in",[5,11]]]',
+ '[["company_id","in",[3]],["company_id","in",[5,11]]]',
+ // 'asustek' checked
+ '[["bar","=",true],["company_id","in",[3]]]',
+ '[["company_id","in",[3]]]',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test('filter with domain', function (assert) {
+ assert.expect(3);
+
+ this.data.company.records.push({id: 40, name: 'child company 1', parent_id: 3});
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'search_panel_select_multi_range') {
+ assert.deepEqual(args.kwargs, {
+ group_by: false,
+ category_domain: [],
+ filter_domain: [],
+ search_domain: [],
+ comodel_domain: [['parent_id', '=', false]],
+ disable_counters: false,
+ });
+ }
+ return result;
+ },
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field select="multi" name="company_id" domain="[(\'parent_id\',\'=\',False)]"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ });
+
+ assert.containsN(kanban, '.o_search_panel_filter_value', 2);
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ 'asustek2agrolait2');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('(un)fold filter group', function (assert) {
+ assert.expect(13);
+
+ this.data.company.records.push({id: 11, name: 'camptocamp', category_id: 7});
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field select="multi" name="company_id" groupby="category_id"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ });
+
+ // groups are opened by default
+ assert.containsN(kanban, '.o_search_panel_filter_group', 2);
+ assert.containsN(kanban, '.o_search_panel_filter_value', 3);
+
+ // check 'agrolait'
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_value:nth(1) input'));
+ var secondGroupCheckbox = kanban.$('.o_search_panel_filter_group:nth(1) > div > input').get(0);
+ assert.strictEqual(secondGroupCheckbox.indeterminate, true);
+ assert.hasAttrValue(kanban.$('.o_search_panel_filter_value:nth(1) input'), 'checked', 'checked');
+
+ // fold second group
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_group:nth(1) .o_toggle_fold'));
+
+ assert.containsN(kanban, '.o_search_panel_filter_group', 2);
+ assert.containsOnce(kanban, '.o_search_panel_filter_value');
+ secondGroupCheckbox = kanban.$('.o_search_panel_filter_group:nth(1) > div > input').get(0);
+ assert.strictEqual(secondGroupCheckbox.indeterminate, true);
+
+ // fold first group
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_group:first .o_toggle_fold'));
+
+ assert.containsN(kanban, '.o_search_panel_filter_group', 2);
+ assert.containsNone(kanban, '.o_search_panel_filter_value');
+
+ // unfold second group
+ testUtils.dom.click(kanban.$('.o_search_panel_filter_group:nth(1) .o_toggle_fold'));
+
+ assert.containsN(kanban, '.o_search_panel_filter_group', 2);
+ assert.containsN(kanban, '.o_search_panel_filter_value', 2);
+ secondGroupCheckbox = kanban.$('.o_search_panel_filter_group:nth(1) > div > input').get(0);
+ assert.strictEqual(secondGroupCheckbox.indeterminate, true);
+ assert.hasAttrValue(kanban.$('.o_search_panel_filter_value:first input'), 'checked', 'checked');
+
+ kanban.destroy();
+ });
+
+ QUnit.test('filter with domain depending on category', function (assert) {
+ assert.expect(22);
+
+ var kanban = createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ var result = this._super.apply(this, arguments);
+ if (args.method === 'search_panel_select_multi_range') {
+ // the following keys should have same value for all calls to this route
+ var keys = ['group_by', 'search_domain', 'filter_domain'];
+ assert.deepEqual(_.pick(args.kwargs, keys), {
+ group_by: false,
+ filter_domain: [],
+ search_domain: [],
+ });
+ assert.step(JSON.stringify(args.kwargs.category_domain));
+ assert.step(JSON.stringify(args.kwargs.comodel_domain));
+ }
+ return result;
+ },
+ services: this.services,
+ arch: '<kanban>' +
+ '<templates><t t-name="kanban-box">' +
+ '<div>' +
+ '<field name="foo"/>' +
+ '</div>' +
+ '</t></templates>' +
+ '<searchpanel>' +
+ '<field name="category_id"/>' +
+ '<field select="multi" name="company_id" domain="[[\'category_id\', \'=\', category_id]]"/>' +
+ '</searchpanel>' +
+ '</kanban>',
+ });
+
+ // select 'gold' category
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value .active');
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(1) .active');
+ assert.containsOnce(kanban, '.o_search_panel_filter_value');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ "asustek1");
+
+ // select 'silver' category
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value:nth(2) .active');
+ assert.containsOnce(kanban, '.o_search_panel_filter_value');
+ assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
+ "agrolait2");
+
+ // select All
+ testUtils.dom.click(kanban.$('.o_search_panel_category_value:first header'));
+
+ assert.containsOnce(kanban, '.o_search_panel_category_value:first .active');
+ assert.containsNone(kanban, '.o_search_panel_filter_value');
+
+ assert.verifySteps([
+ '[]', // category_domain (All)
+ '[["category_id","=",false]]', // comodel_domain (All)
+ '[["category_id","=",6]]', // category_domain ('gold')
+ '[["category_id","=",6]]', // comodel_domain ('gold')
+ '[["category_id","=",7]]', // category_domain ('silver')
+ '[["category_id","=",7]]', // comodel_domain ('silver')
+ '[]', // category_domain (All)
+ '[["category_id","=",false]]', // comodel_domain (All)
+ ]);
+
+ kanban.destroy();
+ });
+});
+
+});
diff --git a/addons/web/views/webclient_templates.xml b/addons/web/views/webclient_templates.xml
index 6ee489b56e4..e4df1400b66 100644
--- a/addons/web/views/webclient_templates.xml
+++ b/addons/web/views/webclient_templates.xml
@@ -304,6 +304,7 @@
<script type="text/javascript" src="/web/static/src/js/views/kanban/kanban_renderer.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/kanban/kanban_renderer_mobile.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/kanban/kanban_view.js"></script>
+ <script type="text/javascript" src="/web/static/src/js/views/kanban/search_panel.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/kanban/quick_create_form_view.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/list/list_editable_renderer.js"></script>
<script type="text/javascript" src="/web/static/src/js/views/list/list_renderer.js"></script>
@@ -562,6 +563,7 @@
<script type="text/javascript" src="/web/static/tests/views/search_filter_menu_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/search_groupby_menu_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/widgets/dropdown_menu_tests.js"></script>
+ <script type="text/javascript" src="/web/static/tests/views/search_panel_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/views/search_view_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/ajax_tests.js"></script>
<script type="text/javascript" src="/web/static/tests/core/registry_tests.js"></script>
diff --git a/doc/reference/views.rst b/doc/reference/views.rst
index c38c5019df2..fbac4763ecb 100644
--- a/doc/reference/views.rst
+++ b/doc/reference/views.rst
@@ -1111,6 +1111,57 @@ Possible children of the view element are:
* kanban-specific CSS
* kanban structures/widgets (vignette, details, ...)
+``searchpanel``
+ allows to display a search panel on the left of the kanban view.
+ This tool allows to quickly filter data on the basis of given fields. The fields
+ are specified as direct children of the ``searchpanel`` with tag name ``field``,
+ and the following attributes:
+
+ * ``name`` (mandatory) the name of the field to filter on
+
+ * ``select`` determines the behavior and display. Possible values are
+
+ ``one`` (default) at most one value can be selected. Supported field types are
+ many2one and selection.
+
+ ``multi`` several values can be selected (checkboxes). Supported field
+ types are many2one, many2many and selection.
+
+ * ``groups``: restricts to specific users
+
+ * ``string``: determines the label to display
+
+ * ``icon``: specifies which icon is used
+
+ * ``color``: determines the icon color
+
+ Additional optional attributes are available in the ``multi`` case:
+
+ * ``domain``: determines conditions that the comodel records have to satisfy.
+
+ A domain might be used to express a dependency on another field (with select="one")
+ of the search panel. Consider
+
+ .. code-block:: xml
+
+ <searchpanel>
+ <field name="department_id"/>
+ <field name="manager_id" select="multi" domain="[('department_id', '=', department_id)]"/>
+ <searchpanel/>
+
+ In the above example, the range of values for manager_id (manager names) available at screen
+ will depend on the value currently selected for the field ``department_id``.
+
+ * ``groupby``: field name of the comodel (only available for many2one and many2many fields). Values will be grouped by that field.
+
+ * ``disable_counters``: default is false. If set to true the counters won't be computed.
+
+ This feature has been implemented in case performances would be too bad.
+
+ Another way to solve performance issues is to properly override the
+ ``search_panel_select_multi_range`` method.
+
+
If you need to extend the Kanban view, see :js:class::`the JS API <KanbanRecord>`.
.. _reference/views/calendar: