summaryrefslogtreecommitdiff
path: root/addons/web/static/src
diff options
context:
space:
mode:
authorAaron Bohy <aab@odoo.com>2019-02-05 12:36:17 +0000
committerMathieu Duckerts-Antoine <dam@odoo.com>2019-02-13 12:23:28 +0000
commit3a5f6bfa9f74a0f038df4f22b97061c436c01281 (patch)
tree72a9fa8362ac46fdbceb59fe9d53756c9bbd0884 /addons/web/static/src
parent33207ed452cb1f3f2dc05a447a137b8cc8cee9ed (diff)
[IMP] web: add kanban searchPanel
This rev. defines a searchPanel widget used in Kanban views to refine search according to specific dimensions. This wigdet is displayed as a sidebar to the left of the kanban view. Part of task 1892462 Co-authored-by: Aaron Bohy <aab@odoo.com> Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com> Co-authored-by: Raphael Collet <rco@odoo.com>
Diffstat (limited to 'addons/web/static/src')
-rw-r--r--addons/web/static/src/js/views/abstract_view.js5
-rw-r--r--addons/web/static/src/js/views/basic/basic_view.js4
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_controller.js65
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js11
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_view.js144
-rw-r--r--addons/web/static/src/js/views/kanban/search_panel.js527
-rw-r--r--addons/web/static/src/scss/kanban_view.scss96
-rw-r--r--addons/web/static/src/xml/kanban.xml105
8 files changed, 944 insertions, 13 deletions
diff --git a/addons/web/static/src/js/views/abstract_view.js b/addons/web/static/src/js/views/abstract_view.js
index 085571a200f..72108e3c8ad 100644
--- a/addons/web/static/src/js/views/abstract_view.js
+++ b/addons/web/static/src/js/views/abstract_view.js
@@ -192,10 +192,6 @@ var AbstractView = Factory.extend({
}
var _super = this._super.bind(this);
return $.when(def).then(function (controlPanel) {
- if (controlPanel) {
- var searchQuery = controlPanel.getSearchQuery();
- self._updateMVCParams(searchQuery);
- }
// get the parent of the model if it already exists, as _super will
// set the new controller as parent, which we don't want
var modelParent = self.model && self.model.getParent();
@@ -249,6 +245,7 @@ var AbstractView = Factory.extend({
return controlPanelView.getController(parent).then(function (controlPanel) {
self.controllerParams.controlPanel = controlPanel;
return controlPanel.appendTo(document.createDocumentFragment()).then(function () {
+ self._updateMVCParams(controlPanel.getSearchQuery());
return controlPanel;
});
});
diff --git a/addons/web/static/src/js/views/basic/basic_view.js b/addons/web/static/src/js/views/basic/basic_view.js
index 39444fdfd75..313bed44708 100644
--- a/addons/web/static/src/js/views/basic/basic_view.js
+++ b/addons/web/static/src/js/views/basic/basic_view.js
@@ -393,7 +393,9 @@ var BasicView = AbstractView.extend({
for (var dependency_name in deps) {
var dependency_dict = {name: dependency_name, type: deps[dependency_name].type};
if (!(dependency_name in fieldsInfo)) {
- fieldsInfo[dependency_name] = _.extend({}, dependency_dict, {options: deps[dependency_name].options || {}});
+ fieldsInfo[dependency_name] = _.extend({}, dependency_dict, {
+ options: deps[dependency_name].options || {},
+ });
}
if (!(dependency_name in fields)) {
fields[dependency_name] = dependency_dict;
diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js
index b51c32aa770..8c6525981f2 100644
--- a/addons/web/static/src/js/views/kanban/kanban_controller.js
+++ b/addons/web/static/src/js/views/kanban/kanban_controller.js
@@ -32,6 +32,7 @@ var KanbanController = BasicController.extend({
kanban_load_records: '_onLoadColumnRecords',
column_toggle_fold: '_onToggleColumn',
kanban_column_records_toggle_active: '_onToggleActiveRecords',
+ search_panel_domain_updated: '_onSearchPanelDomainUpdated',
}),
events: _.extend({}, BasicController.prototype.events, {
click: '_onClick',
@@ -41,13 +42,31 @@ var KanbanController = BasicController.extend({
* @param {Object} params
* @param {boolean} params.quickCreateEnabled set to false to disable the
* quick create feature
+ * @param {SearchPanel} [params.searchPanel]
+ * @param {Array[]} [params.controlPanelDomain=[]] initial domain coming
+ * from the controlPanel
*/
init: function (parent, model, renderer, params) {
this._super.apply(this, arguments);
-
this.on_create = params.on_create;
this.hasButtons = params.hasButtons;
this.quickCreateEnabled = params.quickCreateEnabled;
+
+ // the following attributes are used when there is a searchPanel
+ this._searchPanel = params.searchPanel;
+ this.controlPanelDomain = params.controlPanelDomain || [];
+ this.searchPanelDomain = this._searchPanel ? this._searchPanel.getDomain() : [];
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ if (this._searchPanel) {
+ this.$('.o_content')
+ .addClass('o_kanban_with_searchpanel')
+ .prepend(this._searchPanel.$el);
+ }
+ return this._super.apply(this, arguments);
},
//--------------------------------------------------------------------------
@@ -64,11 +83,37 @@ var KanbanController = BasicController.extend({
widget: this,
}));
this.$buttons.on('click', 'button.o-kanban-button-new', this._onButtonNew.bind(this));
- this.$buttons.on('keydown',this._onButtonsKeyDown.bind(this));
+ this.$buttons.on('keydown', this._onButtonsKeyDown.bind(this));
this._updateButtons();
this.$buttons.appendTo($node);
}
},
+ /**
+ * Override to add the domain coming from the searchPanel (if any) to the
+ * domain coming from the controlPanel.
+ *
+ * @override
+ */
+ update: function (params) {
+ if (!this._searchPanel) {
+ return this._super.apply(this, arguments);
+ }
+ var self = this;
+ if (params.domain) {
+ this.controlPanelDomain = params.domain;
+ }
+ // do not re-render the view as soon as records have been fetched, but
+ // wait for the searchPanel to be ready as well, such that the view
+ // isn't re-rendered before the searchPanel
+ params.noRender = true;
+ params.domain = this.controlPanelDomain.concat(this.searchPanelDomain);
+ var superProm = this._super.apply(this, arguments);
+ var searchPanelProm = this._updateSearchPanel();
+ return $.when(superProm, searchPanelProm).then(function () {
+ // searchPanel has been re-rendered, so re-render the view
+ return self.renderer.render();
+ });
+ },
//--------------------------------------------------------------------------
// Private
@@ -198,6 +243,13 @@ var KanbanController = BasicController.extend({
this.$buttons.find('.o-kanban-button-new').toggleClass('o_hidden', createHidden);
}
},
+ /**
+ * @private
+ * @returns {$.Promise}
+ */
+ _updateSearchPanel: function () {
+ return this._searchPanel.update({searchDomain: this.controlPanelDomain});
+ },
//--------------------------------------------------------------------------
// Handlers
@@ -456,6 +508,15 @@ var KanbanController = BasicController.extend({
/**
* @private
* @param {OdooEvent} ev
+ * @param {Array[]} ev.data.domain the current domain of the searchPanel
+ */
+ _onSearchPanelDomainUpdated: function (ev) {
+ this.searchPanelDomain = ev.data.domain;
+ this.reload({offset: 0});
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
* @param {boolean} [ev.data.openQuickCreate=false] if true, opens the
* QuickCreate in the toggled column (it assumes that we are opening it)
*/
diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js
index 51d75a167c7..252b4587af6 100644
--- a/addons/web/static/src/js/views/kanban/kanban_renderer.js
+++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js
@@ -167,6 +167,17 @@ var KanbanRenderer = BasicRenderer.extend({
this._toggleNoContentHelper();
},
/**
+ * Allow the rendering to be triggered from outside. This is used for kanban
+ * views with a searchPanel, to synchronize updates (the view is updated
+ * with param 'noRender', so that it is not re-rendered before the
+ * searchPanel).
+ *
+ * @returns {$.Promise}
+ */
+ render: function () {
+ return this._render();
+ },
+ /**
* Updates a given column with its new state.
*
* @param {string} localID the column id
diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js
index fd40ca7df93..b7484e7d409 100644
--- a/addons/web/static/src/js/views/kanban/kanban_view.js
+++ b/addons/web/static/src/js/views/kanban/kanban_view.js
@@ -2,12 +2,14 @@ odoo.define('web.KanbanView', function (require) {
"use strict";
var BasicView = require('web.BasicView');
-var core = require('web.core');
var config = require('web.config');
-var KanbanModel = require('web.KanbanModel');
-var KanbanRenderer = require('web.KanbanRenderer');
+var core = require('web.core');
var KanbanController = require('web.KanbanController');
var kanbanExamplesRegistry = require('web.kanban_examples_registry');
+var KanbanModel = require('web.KanbanModel');
+var KanbanRenderer = require('web.KanbanRenderer');
+var pyUtils = require('web.py_utils');
+var SearchPanel = require('web.SearchPanel');
var utils = require('web.utils');
var _lt = core._lt;
@@ -21,6 +23,7 @@ var KanbanView = BasicView.extend({
Model: KanbanModel,
Controller: KanbanController,
Renderer: KanbanRenderer,
+ SearchPanel: SearchPanel,
},
jsLibs: [],
viewType: 'kanban',
@@ -29,7 +32,10 @@ var KanbanView = BasicView.extend({
* @constructor
*/
init: function (viewInfo, params) {
+ this.searchPanelSections = Object.create(null);
+
this._super.apply(this, arguments);
+
this.loadParams.limit = this.loadParams.limit || 40;
// in mobile, columns are lazy-loaded, so set 'openGroupByDefault' to
// false so that they will won't be loaded by the initial load
@@ -81,9 +87,31 @@ var KanbanView = BasicView.extend({
this.controllerParams.hasButtons = true;
this.controllerParams.quickCreateEnabled = this.rendererParams.quickCreateEnabled;
+
if (config.device.isMobile) {
this.jsLibs.push('/web/static/lib/jquery.touchSwipe/jquery.touchSwipe.js');
}
+
+ this.hasSearchPanel = !_.isEmpty(this.searchPanelSections);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override to set the controller as parent of the optional searchPanel
+ *
+ * @override
+ */
+ getController: function () {
+ var self = this;
+ return this._super.apply(this, arguments).then(function (controller) {
+ if (self.hasSearchPanel) {
+ self.controllerParams.searchPanel.setParent(controller);
+ }
+ return controller;
+ });
},
//--------------------------------------------------------------------------
@@ -91,6 +119,53 @@ var KanbanView = BasicView.extend({
//--------------------------------------------------------------------------
/**
+ * Override to create the searchPanel (if necessary) with the domain coming
+ * from the controlPanel
+ *
+ * @override
+ * @private
+ */
+ _createControlPanel: function (parent) {
+ if (!this.hasSearchPanel) {
+ return this._super.apply(this, arguments);
+ }
+ var self = this;
+ return this._super.apply(this, arguments).then(function (controlPanel) {
+ return self._createSearchPanel(parent).then(function () {
+ return controlPanel;
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {Widget} parent
+ * @returns {$.Promise} resolved when the searchPanel is ready
+ */
+ _createSearchPanel: function (parent) {
+ var self = this;
+ var defaultCategoryValues = {};
+ Object.keys(this.loadParams.context).forEach(function (key) {
+ var match = /^searchpanel_default_(.*)$/.exec(key);
+ if (match) {
+ defaultCategoryValues[match[1]] = self.loadParams.context[key];
+ }
+ });
+ var controlPanelDomain = this.loadParams.domain;
+ var searchPanel = new this.config.SearchPanel(parent, {
+ defaultCategoryValues: defaultCategoryValues,
+ fields: this.fields,
+ model: this.loadParams.modelName,
+ searchDomain: controlPanelDomain,
+ sections: this.searchPanelSections,
+ });
+ this.controllerParams.searchPanel = searchPanel;
+ this.controllerParams.controlPanelDomain = controlPanelDomain;
+ return searchPanel.appendTo(document.createDocumentFragment()).then(function () {
+ var searchPanelDomain = searchPanel.getDomain();
+ self.loadParams.domain = controlPanelDomain.concat(searchPanelDomain);
+ });
+ },
+ /**
* @private
* @param {Object} viewInfo
* @returns {boolean} true iff the quick create feature is not explicitely
@@ -105,10 +180,71 @@ var KanbanView = BasicView.extend({
}
return true;
},
+ /**
+ * Override to handle nodes with tagname 'searchpanel'.
+ *
+ * @override
+ * @private
+ */
+ _processNode: function (node, fv) {
+ if (node.tag === 'searchpanel') {
+ if (!config.device.isMobile) {
+ this._processSearchPanelNode(node, fv);
+ }
+ return false;
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Populate this.searchPanelSections with category/filter descriptions.
+ *
+ * @private
+ * @param {Object} node
+ * @param {Object} fv
+ */
+ _processSearchPanelNode: function (node, fv) {
+ var self = this;
+ node.children.forEach(function (childNode, index) {
+ if (childNode.tag !== 'field') {
+ return;
+ }
+ if (childNode.attrs.invisible === "1") {
+ return;
+ }
+ var fieldName = childNode.attrs.name;
+ var type = childNode.attrs.select === 'multi' ? 'filter' : 'category';
+
+ var sectionId = _.uniqueId('section_');
+ var section = {
+ color: childNode.attrs.color,
+ description: childNode.attrs.string || fv.fields[fieldName].string,
+ fieldName: fieldName,
+ icon: childNode.attrs.icon,
+ id: sectionId,
+ index: index,
+ type: type,
+ };
+ if (section.type === 'category') {
+ section.icon = section.icon || 'fa-folder';
+ } else if (section.type === 'filter') {
+ section.disableCounters = !!pyUtils.py_eval(childNode.attrs.disable_counters || '0');
+ section.domain = childNode.attrs.domain || '[]';
+ section.groupBy = childNode.attrs.groupby;
+ section.icon = section.icon || 'fa-filter';
+ }
+ self.searchPanelSections[sectionId] = section;
+ });
+ },
+ /**
+ * @override
+ * @private
+ */
_updateMVCParams: function () {
this._super.apply(this, arguments);
var defaultGroupBy = this.arch.attrs.default_group_by;
- this.loadParams.groupBy = defaultGroupBy ? [defaultGroupBy] : (this.loadParams.groupedBy || []);
+ this.loadParams.groupBy = defaultGroupBy ?
+ [defaultGroupBy] :
+ (this.loadParams.groupedBy || []);
},
});
diff --git a/addons/web/static/src/js/views/kanban/search_panel.js b/addons/web/static/src/js/views/kanban/search_panel.js
new file mode 100644
index 00000000000..9062714e0c1
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/search_panel.js
@@ -0,0 +1,527 @@
+odoo.define('web.SearchPanel', function (require) {
+"use strict";
+
+/**
+ * This file defines the SearchPanel widget for Kanban. It allows to
+ * filter/manage data easily.
+ */
+
+var core = require('web.core');
+var Domain = require('web.Domain');
+var Widget = require('web.Widget');
+
+var qweb = core.qweb;
+
+var SearchPanel = Widget.extend({
+ className: 'o_search_panel',
+ events: {
+ 'click .o_search_panel_category_value header': '_onCategoryValueClicked',
+ 'click .o_search_panel_category_value .o_toggle_fold': '_onToggleFoldCategory',
+ 'click .o_search_panel_filter_group .o_toggle_fold': '_onToggleFoldFilterGroup',
+ 'change .o_search_panel_filter_value > div > input': '_onFilterValueChanged',
+ 'change .o_search_panel_filter_group > div > input': '_onFilterGroupChanged',
+ },
+
+ /**
+ * @override
+ * @param {Object} params
+ * @param {Object} [params.defaultCategoryValues={}] the category value to
+ * activate by default, for each category
+ * @param {Object} params.fields
+ * @param {string} params.model
+ * @param {Object} params.sections
+ * @param {Array[]} params.searchDomain domain coming from controlPanel
+ */
+ init: function (parent, params) {
+ this._super.apply(this, arguments);
+
+ this.categories = _.pick(params.sections, function (section) {
+ return section.type === 'category';
+ });
+ this.filters = _.pick(params.sections, function (section) {
+ return section.type === 'filter';
+ });
+
+ this.defaultCategoryValues = params.defaultCategoryValues || {};
+ this.fields = params.fields;
+ this.model = params.model;
+ this.searchDomain = params.searchDomain;
+ },
+ /**
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ var loadProm = this._fetchCategories().then(function () {
+ return self._fetchFilters();
+ });
+ return $.when(loadProm, this._super.apply(this, arguments));
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this._render();
+ return this._super.apply(this, arguments);
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {Array[]} the current searchPanel domain based on active
+ * categories and checked filters
+ */
+ getDomain: function () {
+ return this._getCategoryDomain().concat(this._getFilterDomain());
+ },
+ /**
+ * Reload the filters and re-render. Note that we only reload the filters if
+ * the controlPanel domain or searchPanel domain has changed.
+ *
+ * @param {Object} params
+ * @param {Array[]} params.searchDomain domain coming from controlPanel
+ * @returns {$.Promise}
+ */
+ update: function (params) {
+ var currentSearchDomainStr = JSON.stringify(this.searchDomain);
+ var newSearchDomainStr = JSON.stringify(params.searchDomain);
+ var def;
+ if (this.needReload || (currentSearchDomainStr !== newSearchDomainStr)) {
+ this.needReload = false;
+ this.searchDomain = params.searchDomain;
+ def = this._fetchFilters();
+ }
+ return $.when(def).then(this._render.bind(this));
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {string} categoryId
+ * @param {Object[]} values
+ */
+ _createCategoryTree: function (categoryId, values) {
+ var category = this.categories[categoryId];
+ var parentField = category.parentField;
+
+ category.values = {};
+ values.forEach(function (value) {
+ category.values[value.id] = _.extend({}, value, {
+ childrenIds: [],
+ folded: true,
+ parentId: value[parentField] && value[parentField][0] || false,
+ });
+ });
+ Object.keys(category.values).forEach(function (valueId) {
+ var value = category.values[valueId];
+ if (value.parentId) {
+ category.values[value.parentId].childrenIds.push(value.id);
+ }
+ });
+ category.rootIds = Object.keys(category.values).filter(function (valueId) {
+ var value = category.values[valueId];
+ return value.parentId === false;
+ });
+
+ // set active value
+ var validValues = _.pluck(category.values, 'id').concat([false]);
+ // set active value from context
+ var value = this.defaultCategoryValues[category.fieldName];
+ // if not set in context, or set to an unknown value, set active value
+ // from localStorage
+ if (!_.contains(validValues, value)) {
+ var storageKey = this._getLocalStorageKey(category);
+ value = this.call('local_storage', 'getItem', storageKey);
+ }
+ // if not set in localStorage either, select 'All'
+ category.activeValueId = _.contains(validValues, value) ? value : false;
+
+ // unfold ancestor values of active value to make it is visible
+ if (category.activeValueId) {
+ var parentValueIds = this._getAncestorValueIds(category, category.activeValueId);
+ parentValueIds.forEach(function (parentValue) {
+ category.values[parentValue].folded = false;
+ });
+ }
+ },
+ /**
+ * @private
+ * @param {string} filterId
+ * @param {Object[]} values
+ */
+ _createFilterTree: function (filterId, values) {
+ var filter = this.filters[filterId];
+
+ // restore checked property
+ values.forEach(function (value) {
+ var oldValue = filter.values && filter.values[value.id];
+ value.checked = oldValue && oldValue.checked || false;
+ });
+
+ filter.values = {};
+ var groupIds = [];
+ if (filter.groupBy) {
+ var groups = {};
+ values.forEach(function (value) {
+ var groupId = value.group_id;
+ if (!groups[groupId]) {
+ if (groupId) {
+ groupIds.push(groupId);
+ }
+ groups[groupId] = {
+ folded: false,
+ id: groupId,
+ name: value.group_name,
+ values: {},
+ tooltip: value.group_tooltip,
+ sequence: value.group_sequence,
+ sortedValueIds: [],
+ };
+ // restore former checked and folded state
+ var oldGroup = filter.groups && filter.groups[groupId];
+ groups[groupId].state = oldGroup && oldGroup.state || false;
+ groups[groupId].folded = oldGroup && oldGroup.folded || false;
+ }
+ groups[groupId].values[value.id] = value;
+ groups[groupId].sortedValueIds.push(value.id);
+ });
+ filter.groups = groups;
+ filter.sortedGroupIds = _.sortBy(groupIds, function (groupId) {
+ return groups[groupId].sequence || groups[groupId].name;
+ });
+ Object.keys(filter.groups).forEach(function (groupId) {
+ filter.values = _.extend(filter.values, filter.groups[groupId].values);
+ });
+ } else {
+ values.forEach(function (value) {
+ filter.values[value.id] = value;
+ });
+ filter.sortedValueIds = values.map(function (value) {
+ return value.id;
+ });
+ }
+ },
+ /**
+ * Fetch values for each category. This is done only once, at startup.
+ *
+ * @private
+ * @returns {$.Promise} resolved when all categories have been fetched
+ */
+ _fetchCategories: function () {
+ var self = this;
+ var defs = Object.keys(this.categories).map(function (categoryId) {
+ var category = self.categories[categoryId];
+ var field = self.fields[category.fieldName];
+ var def;
+ if (field.type === 'selection') {
+ var values = field.selection.map(function (value) {
+ return {id: value[0], display_name: value[1]};
+ });
+ def = $.when(values);
+ } else {
+ def = self._rpc({
+ method: 'search_panel_select_range',
+ model: self.model,
+ args: [category.fieldName],
+ }).then(function (result) {
+ category.parentField = result.parent_field;
+ return result.values;
+ });
+ }
+ return def.then(function (values) {
+ self._createCategoryTree(categoryId, values);
+ });
+ });
+ return $.when.apply($, defs);
+ },
+ /**
+ * Fetch values for each filter. This is done at startup, and at each reload
+ * (when the controlPanel or searchPanel domain changes).
+ *
+ * @private
+ * @returns {$.Promise} resolved when all filters have been fetched
+ */
+ _fetchFilters: function () {
+ var self = this;
+ var evalContext = {};
+ Object.keys(this.categories).forEach(function (categoryId) {
+ var category = self.categories[categoryId];
+ evalContext[category.fieldName] = category.activeValueId;
+ });
+ var categoryDomain = this._getCategoryDomain();
+ var filterDomain = this._getFilterDomain();
+ var defs = Object.keys(this.filters).map(function (filterId) {
+ var filter = self.filters[filterId];
+ return self._rpc({
+ method: 'search_panel_select_multi_range',
+ model: self.model,
+ args: [filter.fieldName],
+ kwargs: {
+ category_domain: categoryDomain,
+ comodel_domain: Domain.prototype.stringToArray(filter.domain, evalContext),
+ disable_counters: filter.disableCounters,
+ filter_domain: filterDomain,
+ group_by: filter.groupBy || false,
+ search_domain: self.searchDomain,
+ },
+ }).then(function (values) {
+ self._createFilterTree(filterId, values);
+ });
+ });
+ return $.when.apply($, defs);
+ },
+ /**
+ * Compute and return the domain based on the current active categories.
+ *
+ * @private
+ * @returns {Array[]}
+ */
+ _getCategoryDomain: function () {
+ var self = this;
+ function categoryToDomain(domain, categoryId) {
+ var category = self.categories[categoryId];
+ if (category.activeValueId) {
+ domain.push([category.fieldName, '=', category.activeValueId]);
+ }
+ return domain;
+ }
+ return Object.keys(this.categories).reduce(categoryToDomain, []);
+ },
+ /**
+ * Compute and return the domain based on the current checked filters.
+ * The values of a single filter are combined using a simple rule: checked values within
+ * a same group are combined with an 'OR' (this is expressed as single condition using a list)
+ * and groups are combined with an 'AND' (expressed by concatenation of conditions).
+ * If a filter has no groups, its checked values are implicitely considered as forming
+ * a group (and grouped using an 'OR').
+ *
+ * @private
+ * @returns {Array[]}
+ */
+ _getFilterDomain: function () {
+ var self = this;
+ function getCheckedValueIds(values) {
+ return Object.keys(values).reduce(function (checkedValues, valueId) {
+ if (values[valueId].checked) {
+ checkedValues.push(values[valueId].id);
+ }
+ return checkedValues;
+ }, []);
+ }
+ function filterToDomain(domain, filterId) {
+ var filter = self.filters[filterId];
+ if (filter.groups) {
+ Object.keys(filter.groups).forEach(function (groupId) {
+ var group = filter.groups[groupId];
+ var checkedValues = getCheckedValueIds(group.values);
+ if (checkedValues.length) {
+ domain.push([filter.fieldName, 'in', checkedValues]);
+ }
+ });
+ } else if (filter.values) {
+ var checkedValues = getCheckedValueIds(filter.values);
+ if (checkedValues.length) {
+ domain.push([filter.fieldName, 'in', checkedValues]);
+ }
+ }
+ return domain;
+ }
+ return Object.keys(this.filters).reduce(filterToDomain, []);
+ },
+ /**
+ * The active id of each category is stored in the localStorage, s.t. it
+ * can be restored afterwards (when the action is reloaded, for instance).
+ * This function returns the key in the sessionStorage for a given category.
+ *
+ * @param {Object} category
+ * @returns {string}
+ */
+ _getLocalStorageKey: function (category) {
+ return 'searchpanel_' + this.model + '_' + category.fieldName;
+ },
+ /**
+ * @private
+ * @param {Object} category
+ * @param {integer} categoryValueId
+ * @returns {integer[]} list of ids of the ancestors of the given value in
+ * the given category
+ */
+ _getAncestorValueIds: function (category, categoryValueId) {
+ var categoryValue = category.values[categoryValueId];
+ var parentId = categoryValue.parentId;
+ if (parentId) {
+ return [parentId].concat(this._getAncestorValueIds(category, parentId));
+ }
+ return [];
+ },
+ /**
+ * @private
+ */
+ _render: function () {
+ var self = this;
+ this.$el.empty();
+
+ // sort categories and filters according to their index
+ var categories = Object.keys(this.categories).map(function (categoryId) {
+ return self.categories[categoryId];
+ });
+ var filters = Object.keys(this.filters).map(function (filterId) {
+ return self.filters[filterId];
+ });
+ var sections = categories.concat(filters).sort(function (s1, s2) {
+ return s1.index - s2.index;
+ });
+
+ sections.forEach(function (section) {
+ if (Object.keys(section.values).length) {
+ if (section.type === 'category') {
+ self.$el.append(self._renderCategory(section));
+ } else {
+ self.$el.append(self._renderFilter(section));
+ }
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {Object} category
+ * @returns {string}
+ */
+ _renderCategory: function (category) {
+ return qweb.render('SearchPanel.Category', {category: category});
+ },
+ /**
+ * @private
+ * @param {Object} filter
+ * @returns {jQuery}
+ */
+ _renderFilter: function (filter) {
+ var $filter = $(qweb.render('SearchPanel.Filter', {filter: filter}));
+
+ // set group inputs in indeterminate state when necessary
+ Object.keys(filter.groups || {}).forEach(function (groupId) {
+ var state = filter.groups[groupId].state;
+ // group 'false' is not displayed
+ if (groupId !== 'false' && state === 'indeterminate') {
+ $filter
+ .find('.o_search_panel_filter_group[data-group-id=' + groupId + '] input')
+ .get(0)
+ .indeterminate = true;
+ }
+ });
+
+ return $filter;
+ },
+ /**
+ * Compute the current searchPanel domain based on categories and filters,
+ * and notify environment of the domain change.
+ *
+ * Note that this assumes that the environment will update the searchPanel.
+ * This is done as such to ensure the coordination between the reloading of
+ * the searchPanel and the reloading of the data.
+ *
+ * @private
+ */
+ _notifyDomainUpdated: function () {
+ this.needReload = true;
+ this.trigger_up('search_panel_domain_updated', {
+ domain: this.getDomain(),
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onCategoryValueClicked: function (ev) {
+ ev.stopPropagation();
+ var $item = $(ev.currentTarget).closest('.o_search_panel_category_value');
+ var category = this.categories[$item.data('categoryId')];
+ var valueId = $item.data('id') || false;
+ category.activeValueId = valueId;
+ var storageKey = this._getLocalStorageKey(category);
+ this.call('local_storage', 'setItem', storageKey, valueId);
+ this._notifyDomainUpdated();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onFilterGroupChanged: function (ev) {
+ ev.stopPropagation();
+ var $item = $(ev.target).closest('.o_search_panel_filter_group');
+ var filter = this.filters[$item.data('filterId')];
+ var groupId = $item.data('groupId');
+ var group = filter.groups[groupId];
+ group.state = group.state === 'checked' ? 'unchecked' : 'checked';
+ Object.keys(group.values).forEach(function (valueId) {
+ group.values[valueId].checked = group.state === 'checked';
+ });
+ this._notifyDomainUpdated();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onFilterValueChanged: function (ev) {
+ ev.stopPropagation();
+ var $item = $(ev.target).closest('.o_search_panel_filter_value');
+ var valueId = $item.data('valueId');
+ var filter = this.filters[$item.data('filterId')];
+ var value = filter.values[valueId];
+ value.checked = !value.checked;
+ var group = filter.groups && filter.groups[value.group_id];
+ if (group) {
+ var valuePartition = _.partition(Object.keys(group.values), function (valueId) {
+ return group.values[valueId].checked;
+ });
+ if (valuePartition[0].length && valuePartition[1].length) {
+ group.state = 'indeterminate';
+ } else if (valuePartition[0].length) {
+ group.state = 'checked';
+ } else {
+ group.state = 'unchecked';
+ }
+ }
+ this._notifyDomainUpdated();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onToggleFoldCategory: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var $item = $(ev.currentTarget).closest('.o_search_panel_category_value');
+ var category = this.categories[$item.data('categoryId')];
+ var valueId = $item.data('id');
+ category.values[valueId].folded = !category.values[valueId].folded;
+ this._render();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onToggleFoldFilterGroup: function (ev) {
+ ev.preventDefault();
+ ev.stopPropagation();
+ var $item = $(ev.currentTarget).closest('.o_search_panel_filter_group');
+ var filter = this.filters[$item.data('filterId')];
+ var groupId = $item.data('groupId');
+ filter.groups[groupId].folded = !filter.groups[groupId].folded;
+ this._render();
+ },
+});
+
+return SearchPanel;
+
+});
diff --git a/addons/web/static/src/scss/kanban_view.scss b/addons/web/static/src/scss/kanban_view.scss
index c4c0e9d011d..b9abdb14d48 100644
--- a/addons/web/static/src/scss/kanban_view.scss
+++ b/addons/web/static/src/scss/kanban_view.scss
@@ -1,4 +1,3 @@
-
// ------- Kanban View Layout -------
.o_kanban_view {
display: flex;
@@ -569,3 +568,98 @@
z-index: 1; // show the title over kanban color
}
}
+
+// ------- Kanban with SearchPanel -------
+$o-searchpanel-p: $o-horizontal-padding;
+$o-searchpanel-p-small: $o-horizontal-padding*0.5;
+$o-searchpanel-p-tiny: $o-searchpanel-p-small*0.5;
+
+$o-searchpanel-category-default-color: $o-brand-primary;
+$o-searchpanel-filter-default-color: #D59244;
+
+.o_kanban_with_searchpanel {
+ display: flex;
+ align-items: flex-start;
+
+ .o_kanban_view {
+ flex: 1 1 100%;
+ overflow: auto; // make the kanban renderer and search panel scroll individually
+ max-height: 100%;
+ }
+ .o_search_panel {
+ flex: 0 0 220px;
+ overflow: auto;
+ height: 100%;
+ padding: $o-searchpanel-p-small $o-searchpanel-p-small $o-searchpanel-p*2 $o-searchpanel-p;
+ border-right: 1px solid $gray-300;
+ background-color: white;
+
+ .o_search_panel_category .o_search_panel_section_icon {
+ color: $o-brand-odoo;
+ }
+ .o_search_panel_filter .o_search_panel_section_icon {
+ color: $o-searchpanel-filter-default-color;
+ }
+
+ .o_search_panel_label {
+ cursor: pointer;
+ user-select: none;
+
+ .o_toggle_fold {
+ padding: 3px;
+ }
+ }
+ .o_search_panel_section_header {
+ padding: $o-searchpanel-p-small 0;
+ }
+ .list-group-item {
+ padding: 0 0 $o-searchpanel-p-small 0;
+
+ .list-group-item {
+ padding: 0 0 0 $custom-control-gutter;
+ margin-bottom: $o-searchpanel-p-tiny*0.5;
+ &:first-child {
+ margin-top: $o-searchpanel-p-tiny*0.5;
+ }
+ }
+ span.o_search_panel_label_title {
+ color: $headings-color;
+ @include o-text-overflow(inline-block, calc(100% - 22px));
+ }
+ header.active {
+ background-color: $list-group-action-active-bg;
+ }
+ }
+ .o_search_panel_category_value {
+ header {
+ margin-left: -$o-searchpanel-p-tiny;
+ padding-left: $o-searchpanel-p-tiny;
+ }
+ .o_search_panel_category_value {
+ position: relative;
+ padding-left: $o-searchpanel-p;
+ padding-bottom: $o-searchpanel-p-tiny;
+ margin-bottom: 0;
+
+ &:before, &:after {
+ @include o-position-absolute(0, $left: $o-searchpanel-p-tiny)
+ @include size(1px, 100%);
+ background: $gray-500;
+ content: '';
+ }
+ &:after {
+ top: 10px;
+ @include size(8px, 1px);
+ }
+ &:last-child {
+ &:before {
+ height: 11px;
+ }
+ &:after {
+ top: 11px;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/addons/web/static/src/xml/kanban.xml b/addons/web/static/src/xml/kanban.xml
index 06ba6b71eb6..8a9ac5fdb7d 100644
--- a/addons/web/static/src/xml/kanban.xml
+++ b/addons/web/static/src/xml/kanban.xml
@@ -192,6 +192,110 @@
</div>
</t>
+<!-- Kanban SearchPanel -->
+<t t-name="SearchPanel.SectionHeader">
+ <header class="o_search_panel_section_header text-uppercase">
+ <i t-attf-class="mr-1 fa #{section.icon} o_search_panel_section_icon" t-attf-style="#{section.color ? ('color: ' + section.color) : ''}"/>
+ <b><t t-esc="section.description"/></b>
+ </header>
+</t>
+
+<t t-name="SearchPanel.Category">
+ <section class="o_search_panel_section o_search_panel_category">
+ <t t-call="SearchPanel.SectionHeader">
+ <t t-set="section" t-value="category"/>
+ </t>
+ <ul class="list-group d-block">
+ <li class="o_search_panel_category_value border-0 list-group-item" t-att-data-category-id="category.id">
+ <header t-att-class="'list-group-item-action' + (!category.activeValueId ? ' active' : '')">
+ <label class="o_search_panel_label mb0 d-block">
+ <span class="o_search_panel_label_title"><b>All</b></span>
+ </label>
+ </header>
+ </li>
+ <t t-call="SearchPanel.CategoryValues">
+ <t t-set="values" t-value="category.rootIds"/>
+ </t>
+ </ul>
+ </section>
+</t>
+<t t-name="SearchPanel.CategoryValues">
+ <t t-foreach="values" t-as="valueId">
+ <t t-set="value" t-value="category.values[valueId]"/>
+ <li class="o_search_panel_category_value border-0 list-group-item" t-att-data-id="value.id" t-att-data-category-id="category.id">
+ <header t-att-class="'list-group-item-action' + (value.id === category.activeValueId ? ' active' : '')">
+ <label t-att-for="value.display_name" class="o_search_panel_label mb0 d-block">
+ <i t-if="value.childrenIds.length" t-att-class="'fa fa-fw pull-right o_toggle_fold ' + (value.folded ? 'fa-caret-left' : 'fa-caret-down')"/>
+ <span class="o_search_panel_label_title"><t t-esc="value.display_name"/></span>
+ </label>
+ </header>
+ <ul t-if="!value.folded" class="list-group d-block">
+ <t t-call="SearchPanel.CategoryValues">
+ <t t-set="values" t-value="value.childrenIds"/>
+ </t>
+ </ul>
+ </li>
+ </t>
+</t>
+
+<t t-name="SearchPanel.Filter">
+ <section class="o_search_panel_section o_search_panel_filter">
+ <t t-call="SearchPanel.SectionHeader">
+ <t t-set="section" t-value="filter"/>
+ </t>
+ <ul class="list-group d-block">
+ <t t-if="filter.groups" t-call="SearchPanel.FilterGroups">
+ <t t-set="groups" t-value="filter.groups"/>
+ </t>
+ <t t-else="" t-call="SearchPanel.FilterValues">
+ <t t-set="sortedValueIds" t-value="filter.sortedValueIds"/>
+ <t t-set="values" t-value="filter.values"/>
+ </t>
+ </ul>
+ </section>
+</t>
+<t t-name="SearchPanel.FilterGroups">
+ <li t-foreach="filter.sortedGroupIds" t-as="groupId" t-att-data-group-id="groupId" t-att-data-filter-id="filter.id"
+ class="o_search_panel_filter_group list-group-item border-0">
+ <t t-set="group" t-value="groups[groupId]"/>
+ <div class="custom-control custom-checkbox">
+ <t t-set="inputId" t-value="_.uniqueId('input_')"/>
+ <input type="checkbox" t-att-id="inputId" t-att-checked="group.state === 'checked' ? 'checked' : undefined" class="custom-control-input"/>
+ <label t-att-for="inputId" class="o_search_panel_label custom-control-label d-block" t-att-title="group.tooltip">
+ <i t-att-class="'fa fa-fw pull-right o_toggle_fold ' + (group.folded ? 'fa-caret-left' : 'fa-caret-down')"/>
+ <span class="o_search_panel_label_title"><t t-esc="group.name"/></span>
+ </label>
+ </div>
+ <ul t-if="!group.folded" class="list-group d-block">
+ <t t-call="SearchPanel.FilterValues">
+ <t t-set="sortedValueIds" t-value="group.sortedValueIds"/>
+ <t t-set="values" t-value="group.values"/>
+ </t>
+ </ul>
+ </li>
+ <ul t-if="groups['false']" class="list-group d-block">
+ <t t-call="SearchPanel.FilterValues">
+ <t t-set="group" t-value="groups['false']"/>
+ <t t-set="sortedValueIds" t-value="group.sortedValueIds"/>
+ <t t-set="values" t-value="group.values"/>
+ </t>
+ </ul>
+</t>
+<t t-name="SearchPanel.FilterValues">
+ <li t-foreach="sortedValueIds" t-as="valueId" t-att-data-value-id="valueId" t-att-data-filter-id="filter.id"
+ class="o_search_panel_filter_value list-group-item border-0">
+ <t t-set="value" t-value="values[valueId]"/>
+ <div class="custom-control custom-checkbox">
+ <t t-set="inputId" t-value="_.uniqueId('input_')"/>
+ <input type="checkbox" t-att-id="inputId" t-att-checked="value.checked ? 'checked' : undefined" class="custom-control-input"/>
+ <label t-att-for="inputId" class="o_search_panel_label custom-control-label d-block" t-att-title="group &amp;&amp; group.tooltip">
+ <span class="o_search_panel_label_title"><t t-esc="value.name"/></span>
+ <span t-if="value.count > 0" class="pull-right text-muted mr-2 mt-1 small"><t t-esc="value.count"/></span>
+ <span t-if="filter.disableCounters" class="pull-right text-muted mr-2 mt-1 small">?</span>
+ </label>
+ </div>
+ </li>
+</t>
<!-- Mobile templates -->
<t t-name="KanbanView.MobileTabs">
@@ -203,5 +307,4 @@
</t>
</div>
</t>
-
</template>