[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>
This commit is contained in:
Aaron Bohy
2019-02-13 12:23:28 +00:00
committed by Mathieu Duckerts-Antoine
co-authored by Mathieu Duckerts-Antoine Raphael Collet
parent 33207ed452
commit 3a5f6bfa9f
14 changed files with 2748 additions and 13 deletions
+155
View File
@@ -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
@@ -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;
});
});
@@ -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;
@@ -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
@@ -453,6 +505,15 @@ var KanbanController = BasicController.extend({
self._updateEnv();
});
},
/**
* @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
@@ -166,6 +166,17 @@ var KanbanRenderer = BasicRenderer.extend({
this.quickCreate.toggleFold();
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.
*
@@ -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,15 +87,84 @@ 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;
});
},
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
/**
* 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
@@ -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 || []);
},
});
@@ -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;
});
+95 -1
View File
@@ -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;
}
}
}
}
}
}
+104 -1
View File
@@ -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>
@@ -470,6 +470,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
*
@@ -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));
@@ -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();
});
});
});
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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>
+51
View File
@@ -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: