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
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;
|
|
|
|
});
|