[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:
committed by
Mathieu Duckerts-Antoine
co-authored by
Mathieu Duckerts-Antoine
Raphael Collet
parent
33207ed452
commit
3a5f6bfa9f
@@ -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;
|
||||
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user