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 /addons/web/static/src | |
| 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>
Diffstat (limited to 'addons/web/static/src')
| -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 |
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 && 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> |
