diff options
| author | Aaron Bohy <aab@odoo.com> | 2019-02-05 12:36:17 +0000 |
|---|---|---|
| committer | Mathieu Duckerts-Antoine <dam@odoo.com> | 2019-02-13 12:23:28 +0000 |
| commit | 3a5f6bfa9f74a0f038df4f22b97061c436c01281 (patch) | |
| tree | 72a9fa8362ac46fdbceb59fe9d53756c9bbd0884 | |
| parent | 33207ed452cb1f3f2dc05a447a137b8cc8cee9ed (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.py | 155 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/abstract_view.js | 5 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/basic/basic_view.js | 4 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/kanban/kanban_controller.js | 65 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/kanban/kanban_renderer.js | 11 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/kanban/kanban_view.js | 144 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/kanban/search_panel.js | 527 | ||||
| -rw-r--r-- | addons/web/static/src/scss/kanban_view.scss | 96 | ||||
| -rw-r--r-- | addons/web/static/src/xml/kanban.xml | 105 | ||||
| -rw-r--r-- | addons/web/static/tests/helpers/mock_server.js | 119 | ||||
| -rw-r--r-- | addons/web/static/tests/views/kanban_mobile_tests.js | 29 | ||||
| -rw-r--r-- | addons/web/static/tests/views/search_panel_tests.js | 1448 | ||||
| -rw-r--r-- | addons/web/views/webclient_templates.xml | 2 | ||||
| -rw-r--r-- | doc/reference/views.rst | 51 |
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 && 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: |
