You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

650 lines
24 KiB

/**********************************************************************************
*
* Copyright (c) 2017-2019 MuK IT GmbH.
*
* This file is part of MuK Search Panel
* (see https://mukit.at).
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
**********************************************************************************/
odoo.define('web.SearchPanel', function (require) {
"use strict";
var core = require('web.core');
var config = require('web.config');
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;
this.loadProm = $.Deferred();
this.loadPromLazy = true;
},
willStart: function () {
var self = this;
var loading = $.Deferred();
var loadPromTimer = setTimeout(function () {
if(loading.state() !== 'resolved') {
loading.resolve();
}
}, this.loadPromMaxTime || 1000);
this._fetchCategories().then(function () {
self._fetchFilters().then(function () {
if(loading.state() !== 'resolved') {
clearTimeout(loadPromTimer);
self.loadPromLazy = false;
loading.resolve();
}
self.loadProm.resolve();
});
});
return $.when(loading, this._super.apply(this, arguments));
},
start: function () {
var self = this;
if(this.loadProm.state() !== 'resolved') {
this.$el.html($("<div/>", {
'class': "o_search_panel_loading",
'html': "<i class='fa fa-spinner fa-pulse' />"
}));
}
this.loadProm.then(function() {
self._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) {
if(this.loadProm.state() === 'resolved') {
var newSearchDomainStr = JSON.stringify(params.searchDomain);
var currentSearchDomainStr = JSON.stringify(this.searchDomain);
if (this.needReload || (currentSearchDomainStr !== newSearchDomainStr)) {
this.needReload = false;
this.searchDomain = params.searchDomain;
this._fetchFilters().then(this._render.bind(this));
} else {
this._render();
}
}
return $.when();
},
//--------------------------------------------------------------------------
// 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]) {
category.values[value.parentId].childrenIds.push(value.id);
} else {
value.parentId = false;
value[parentField] = false;
}
});
category.rootIds = Object.keys(category.values).filter(function (valueId) {
var value = category.values[valueId];
return value.parentId === false;
});
category.activeValueId = false;
if(!this.loadPromLazy) {
// 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 {
var categoryDomain = self._getCategoryDomain();
var filterDomain = self._getFilterDomain();
def = self._rpc({
method: 'search_panel_select_range',
model: self.model,
args: [category.fieldName],
kwargs: {
category_domain: categoryDomain,
filter_domain: filterDomain,
search_domain: self.searchDomain,
},
}, {
shadow: true,
}).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,
},
}, {
shadow: true,
}).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]);
} else if(self.loadPromLazy && self.loadProm.state() !== 'resolved') {
var value = self.defaultCategoryValues[category.fieldName];
if (value) {
domain.push([category.fieldName, '=', value]);
}
}
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();
},
});
if (config.device.isMobile) {
SearchPanel.include({
tagName: 'details',
_getCategorySelection: function () {
var self = this;
return Object.keys(this.categories).reduce(function (selection, categoryId) {
var category = self.categories[categoryId];
console.log('category', category);
if (category.activeValueId) {
var ancestorIds = [category.activeValueId].concat(self._getAncestorValueIds(category, category.activeValueId));
var breadcrumb = ancestorIds.map(function (valueId) {
return category.values[valueId].display_name;
});
selection.push({ breadcrumb: breadcrumb, icon: category.icon, color: category.color});
}
console.log('selection', selection);
return selection;
}, []);
},
_getFilterSelection: function () {
var self = this;
return Object.keys(this.filters).reduce(function (selection, filterId) {
var filter = self.filters[filterId];
console.log('filter', filter);
if (filter.groups) {
Object.keys(filter.groups).forEach(function (groupId) {
var group = filter.groups[groupId];
Object.keys(group.values).forEach(function (valueId) {
var value = group.values[valueId];
if (value.checked) {
selection.push({name: value.name, icon: filter.icon, color: filter.color});
}
});
});
} else if (filter.values) {
Object.keys(filter.values).forEach(function (valueId) {
var value = filter.values[valueId];
if (value.checked) {
selection.push({name: value.name, icon: filter.icon, color: filter.color});
}
});
}
console.log('selection', selection);
return selection;
}, []);
},
_render: function () {
this._super.apply(this, arguments);
this.$el.prepend(qweb.render('SearchPanel.MobileSummary', {
categories: this._getCategorySelection(),
filterValues: this._getFilterSelection(),
separator: ' / ',
}));
},
});
}
return SearchPanel;
});