summaryrefslogtreecommitdiff
path: root/addons/web/static
diff options
context:
space:
mode:
authorMathieu Duckerts-Antoine <dam@odoo.com>2020-04-28 12:43:30 +0000
committerMathieu Duckerts-Antoine <dam@odoo.com>2020-05-05 19:23:20 +0000
commit2c102c292f22f5d400d8caf779428fdc55c15e43 (patch)
tree6436980060455bd35619672d5e0ba693551e3902 /addons/web/static
parentc19dcf3983c797d82cd808d9701e190aee619af2 (diff)
[IMP] web: search panel counter changes
This commit introduces several changes in the search panel with respect to record counts: - the record counts are now also available for the fields with select="one" attribute (if not disabled explicitely). - the record counts are better computed using the idea that selected values within a group should not impact the counts for the group values but only the counts for the other group values. On the way we have changed two keys in the values returned by the server: - 'count' becomes '__count'. It has been done to avoid a possible clash in case a model would have a field named 'count' and that the field values would be wanted for some reason. - 'name' (multi case) becomes 'display_name'. It has been done in order to make the select one and multi cases more similar and factorize some code. TASK-ID: 2166814 Co-authored-by: Raphaël Collet <rco@openerp.com> Co-authored-by: Mathieu Duckerts-Antoine <dam@odoo.com> Co-authored-by: Alexis Lacroix <laa@odoo.com> Co-authored-by: Julien Mougenot <jum@odoo.com>
Diffstat (limited to 'addons/web/static')
-rw-r--r--addons/web/static/src/js/views/search_panel.js170
-rw-r--r--addons/web/static/src/scss/search_panel.scss32
-rw-r--r--addons/web/static/src/xml/kanban.xml28
-rw-r--r--addons/web/static/tests/helpers/mock_server.js299
-rw-r--r--addons/web/static/tests/views/search_panel_tests.js321
5 files changed, 677 insertions, 173 deletions
diff --git a/addons/web/static/src/js/views/search_panel.js b/addons/web/static/src/js/views/search_panel.js
index a820e46f529..ef1fd05e4fb 100644
--- a/addons/web/static/src/js/views/search_panel.js
+++ b/addons/web/static/src/js/views/search_panel.js
@@ -44,6 +44,7 @@ function _processSearchPanelNode(node, fields) {
const section = {
color: childNode.attrs.color,
description: childNode.attrs.string || fields[fieldName].string,
+ disableCounters: !!pyUtils.py_eval(childNode.attrs.disable_counters || '0'),
fieldName: fieldName,
icon: childNode.attrs.icon,
id: sectionId,
@@ -53,7 +54,6 @@ function _processSearchPanelNode(node, fields) {
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';
@@ -110,6 +110,7 @@ const SearchPanel = Widget.extend({
this.fields = params.fields;
this.model = params.model;
this.className = params.classes.concat(['o_search_panel']).join(' ');
+ this.reloadOptions = null;
this.searchDomain = params.searchDomain;
this.viewDomain = params.viewDomain;
},
@@ -122,7 +123,7 @@ const SearchPanel = Widget.extend({
this.filters = this.initialState.filters;
this.categories = this.initialState.categories;
} else {
- await this._fetchCategories();
+ await this._fetchCategories(true);
await this._fetchFilters();
await this._applyDefaultFilterValues();
}
@@ -221,8 +222,8 @@ const SearchPanel = Widget.extend({
this._render();
},
/**
- * Reload the filters and re-render. Note that we only reload the filters if
- * the controlPanel domain or searchPanel domain has changed.
+ * Reload the filters and categories and re-render. Note that we only reload them
+ * if the controlPanel domain or searchPanel domain has changed.
*
* @param {Object} params
* @param {Array[]} params.searchDomain domain coming from controlPanel
@@ -232,11 +233,14 @@ const SearchPanel = Widget.extend({
async update(params) {
const currentDomain = JSON.stringify([...this.searchDomain, ...this.viewDomain]);
const newDomain = JSON.stringify([...params.searchDomain, ...params.viewDomain]);
- if (this.needReload || (currentDomain !== newDomain)) {
- this.needReload = false;
+ if (this.reloadOptions || (currentDomain !== newDomain)) {
this.searchDomain = params.searchDomain;
this.viewDomain = params.viewDomain;
+ if (!this.reloadOptions || this.reloadOptions.shouldFetchCategories) {
+ await this._fetchCategories();
+ }
await this._fetchFilters();
+ this.reloadOptions = null;
}
return this._render();
},
@@ -277,13 +281,16 @@ const SearchPanel = Widget.extend({
parentField = false;
}
}
+ const unfoldedIds = Object.values(category.values || {})
+ .filter(c => c.folded === false)
+ .map(c => c.id);
category.values = {};
values.forEach(value => {
category.values[value.id] = Object.assign({}, value, {
childrenIds: [],
- folded: true,
- parentId: value[parentField] && value[parentField][0] || false,
+ folded: !unfoldedIds.includes(value.id),
+ parentId: value[parentField] || false,
});
});
values.forEach(value => {
@@ -374,35 +381,35 @@ const SearchPanel = Widget.extend({
}
},
/**
- * Fetch values for each category. This is done only once, at startup.
+ * Fetch values for each category at startup. At reload a category is only
+ * fetched if the searchDomain changes and displayCounters is true for it.
*
* @private
+ * @param {boolean} [force]
* @returns {Promise} resolved when all categories have been fetched
*/
- _fetchCategories() {
+ _fetchCategories(force) {
const proms = [];
- let prom;
for (const category of Object.values(this.categories)) {
const field = this.fields[category.fieldName];
- if (field.type === 'selection') {
- const values = field.selection.map(value => {
- return { id: value[0], display_name: value[1] };
- });
- prom = Promise.resolve(values);
- } else {
- prom = this._rpc({
+ if (force || !category.disableCounters) {
+ const prom = this._rpc({
method: 'search_panel_select_range',
model: this.model,
args: [category.fieldName],
- }).then(result => {
- category.parentField = result.parent_field;
- return result.values;
+ kwargs: {
+ category_domain: this._getCategoryDomain(category.id),
+ disable_counters: category.disableCounters,
+ search_domain: this.searchDomain,
+ },
+ }).then(({ parent_field, values }) => {
+ if (field.type === 'many2one') {
+ category.parentField = parent_field;
+ }
+ this._createCategoryTree(category.id, values);
});
+ proms.push(prom);
}
- prom.then(values => {
- this._createCategoryTree(category.id, values);
- });
- proms.push(prom);
}
return Promise.all(proms);
},
@@ -419,7 +426,6 @@ const SearchPanel = Widget.extend({
evalContext[category.fieldName] = category.activeValueId;
}
const categoryDomain = this._getCategoryDomain();
- const filterDomain = this._getFilterDomain();
const proms = [];
for (const filter of Object.values(this.filters)) {
const prom = this._rpc({
@@ -430,8 +436,9 @@ const SearchPanel = Widget.extend({
category_domain: categoryDomain,
comodel_domain: Domain.prototype.stringToArray(filter.domain, evalContext),
disable_counters: filter.disableCounters,
- filter_domain: filterDomain,
+ filter_domain: this._getFilterDomain(filter.id),
group_by: filter.groupBy || false,
+ group_domain: this._getGroupDomain(filter),
search_domain: [...this.searchDomain, ...this.viewDomain],
},
}).then(values => { this._createFilterTree(filter.id, values); });
@@ -443,7 +450,7 @@ const SearchPanel = Widget.extend({
* @private
* @param {Object} category
* @param {Array} validValues
- * @returns id of the default item of the category or false
+ * @returns {(number|boolean)} id of the default item of the category or false
*/
_getCategoryDefaultValue(category, validValues) {
// set active value from context
@@ -460,11 +467,15 @@ const SearchPanel = Widget.extend({
* Compute and return the domain based on the current active categories.
*
* @private
+ * @param {string} [excludedCategoryId]
* @returns {Array[]}
*/
- _getCategoryDomain() {
+ _getCategoryDomain(excludedCategoryId) {
const domain = [];
for (const category of Object.values(this.categories)) {
+ if (category.id === excludedCategoryId) {
+ continue;
+ }
if (category.activeValueId) {
const field = this.fields[category.fieldName];
const op = (field.type === 'many2one' && category.parentField) ? 'child_of' : '=';
@@ -482,10 +493,10 @@ const SearchPanel = Widget.extend({
* a group (and grouped using an 'OR').
*
* @private
- * @param {string} filterId
+ * @param {string} [excludedFilterId]
* @returns {Array[]}
*/
- _getFilterDomain(filterId) {
+ _getFilterDomain(excludedFilterId) {
const domain = [];
function addCondition(fieldName, checkedValues) {
@@ -496,7 +507,7 @@ const SearchPanel = Widget.extend({
}
for (const filter of Object.values(this.filters)) {
- if (filter.id === filterId) {
+ if (filter.id === excludedFilterId) {
continue;
}
const { fieldName } = filter;
@@ -514,6 +525,81 @@ const SearchPanel = Widget.extend({
return domain;
},
/**
+ * Returns a domain or an object of domains used to complement
+ * the filter domains to accurately describe the constraints on
+ * records when computing record counts associated with the filter
+ * values (if a groupBy is provided). The idea is that the checked values
+ * within a group should not impact the counts for the other values
+ * in the same group.
+ *
+ * @private
+ * @param {string} filter
+ * @returns {(Array{}|Array[]|undefined)}
+ */
+ _getGroupDomain(filter) {
+ const { fieldName, groups, disableCounters } = filter;
+ const { type: fieldType } = this.fields[fieldName];
+
+ if (disableCounters || !groups) {
+ switch (fieldType) {
+ case 'many2one': return [];
+ case 'many2many': return {};
+ default: return;
+ }
+ }
+
+ let groupDomain;
+ if (fieldType === 'many2one') {
+ for (const group of Object.values(groups)) {
+ const valueIds = [];
+ let active = false;
+ for (const value of Object.values(group.values || {})) {
+ const { id, checked } = value;
+ valueIds.push(id);
+ if (checked) {
+ active = true;
+ }
+ }
+ if (active) {
+ if (groupDomain) {
+ groupDomain = Domain.FALSE_DOMAIN;
+ break;
+ } else {
+ groupDomain = [[fieldName, 'in', valueIds]];
+ }
+ }
+ }
+ } else if (fieldType === 'many2many') {
+ const checkedValueIds = {};
+ for (const group of Object.values(groups)) {
+ for (const value of Object.values(group.values || {})) {
+ const { id, checked } = value;
+ if (checked) {
+ if (!checkedValueIds[group.id]) {
+ checkedValueIds[group.id] = [];
+ }
+ checkedValueIds[group.id].push(id);
+ }
+ }
+ }
+ groupDomain = {};
+ for (const gId in checkedValueIds) {
+ const ids = checkedValueIds[gId];
+ for (const group of Object.values(groups)) {
+ if (gId !== group.id) {
+ if (!groupDomain[group.id]) {
+ groupDomain[group.id] = [];
+ }
+ groupDomain[group.id].push(
+ [fieldName, 'in', ids]
+ );
+ }
+ }
+ }
+ }
+ return groupDomain;
+ },
+ /**
* 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.
@@ -527,8 +613,8 @@ const SearchPanel = Widget.extend({
/**
* @private
* @param {Object} category
- * @param {integer} categoryValueId
- * @returns {integer[]} list of ids of the ancestors of the given value in
+ * @param {number} categoryValueId
+ * @returns {number[]} list of ids of the ancestors of the given value in
* the given category
*/
_getAncestorValueIds(category, categoryValueId) {
@@ -547,9 +633,10 @@ const SearchPanel = Widget.extend({
* the searchPanel and the reloading of the data.
*
* @private
+ * @param {Object} [reloadOptions={}]
*/
- _notifyDomainUpdated() {
- this.needReload = true;
+ _notifyDomainUpdated(reloadOptions = {}) {
+ this.reloadOptions = reloadOptions;
this.trigger_up('search_panel_domain_updated', {
domain: this.getDomain(),
});
@@ -638,13 +725,18 @@ const SearchPanel = Widget.extend({
const valueId = !isNaN(item.dataset.id) ?
Number(item.dataset.id) :
item.dataset.id || false;
+ const hasChanged = category.activeValueId !== valueId;
category.activeValueId = valueId;
if (category.values[valueId]) {
category.values[valueId].folded = !category.values[valueId].folded;
}
- const storageKey = this._getLocalStorageKey(category);
- this.call('local_storage', 'setItem', storageKey, valueId);
- this._notifyDomainUpdated();
+ if (hasChanged) {
+ const storageKey = this._getLocalStorageKey(category);
+ this.call('local_storage', 'setItem', storageKey, valueId);
+ this._notifyDomainUpdated({ shouldFetchCategories: true });
+ } else {
+ this._render();
+ }
},
/**
* @private
diff --git a/addons/web/static/src/scss/search_panel.scss b/addons/web/static/src/scss/search_panel.scss
index 0c7b2266a25..ac7f05d3169 100644
--- a/addons/web/static/src/scss/search_panel.scss
+++ b/addons/web/static/src/scss/search_panel.scss
@@ -32,13 +32,19 @@ $o-searchpanel-filter-default-color: #D59244;
}
.o_search_panel_label {
- display: flex;
+ align-items: center;
cursor: pointer;
+ display: flex;
+ justify-content: space-between;
user-select: none;
+ // Without counters: the caret is set at the right border
+ &:not(.o_with_counters) {
+ width: 100%;
+ }
+
.o_toggle_fold {
- margin-left: auto;
- padding: 3px;
+ width: 2rem;
}
}
.o_search_panel_section_header {
@@ -64,14 +70,17 @@ $o-searchpanel-filter-default-color: #D59244;
}
.o_search_panel_category_value {
cursor: pointer;
+
header {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
margin-top: -4px;
margin-bottom: -4px;
- padding: 4px 7px;
-
- .o_toggle_fold {
- padding-left: 11px;
- }
+ padding: 4px 6px;
+ }
+ .o_search_panel_label.o_with_counters {
+ overflow: hidden;
}
.o_search_panel_category_value {
position: relative;
@@ -99,5 +108,12 @@ $o-searchpanel-filter-default-color: #D59244;
}
}
}
+ .o_search_panel_filter_group {
+ padding-right: 6px;
+
+ .o_search_panel_label.o_with_counters {
+ justify-content: flex-start;
+ }
+ }
}
}
diff --git a/addons/web/static/src/xml/kanban.xml b/addons/web/static/src/xml/kanban.xml
index cb34cd16851..970f9fd9825 100644
--- a/addons/web/static/src/xml/kanban.xml
+++ b/addons/web/static/src/xml/kanban.xml
@@ -245,10 +245,14 @@
<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">
+ <label t-att-for="value.display_name" t-attf-class="o_search_panel_label mb0{{ category.disableCounters ? '' : ' o_with_counters' }}">
<span class="o_search_panel_label_title"><t t-esc="value.display_name"/></span>
- <i t-if="value.childrenIds.length" t-attf-class="fa fa-fw o_toggle_fold {{value.folded ? 'fa-caret-left' : 'fa-caret-down'}}"/>
+ <i t-if="value.childrenIds.length" t-attf-class="o_toggle_fold fa fa-fw fa-caret-{{ value.folded ? 'right' : 'down' }}"/>
</label>
+ <span t-if="!category.disableCounters and value.__count > 0"
+ class="text-muted ml-2 small"
+ t-esc="value.__count"
+ />
</header>
<ul t-if="!value.folded" class="list-group d-block">
<t t-call="SearchPanel.CategoryValues">
@@ -285,10 +289,12 @@
<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 t-if="group.hex_color" t-att-style="'color: ' + group.hex_color + ';'">●</span>
- <span class="o_search_panel_label_title"><t t-esc="group.name"/></span>
+ <label t-att-for="inputId" t-attf-class="o_search_panel_label custom-control-label{{ group.disableCounters ? '' : ' o_with_counters' }}" t-att-title="group.tooltip">
+ <span class="o_search_panel_label_title">
+ <span t-if="group.hex_color" t-attf-style="color: {{ group.hex_color }};">●</span>
+ <t t-esc="group.name"/>
+ </span>
+ <i t-attf-class="o_toggle_fold fa fa-fw fa-caret-{{ group.folded ? 'right' : 'down' }}"/>
</label>
</div>
<ul t-if="!group.folded" class="list-group d-block">
@@ -313,10 +319,12 @@
<div class="custom-control custom-checkbox">
<t t-set="inputId" t-value="_.uniqueId('input_')"/>
<input type="checkbox" t-att-id="inputId" t-att-checked="value.checked ? 'checked' : undefined" class="custom-control-input"/>
- <label t-att-for="inputId" class="o_search_panel_label custom-control-label d-block" t-att-title="group &amp;&amp; group.tooltip">
- <span class="o_search_panel_label_title"><t t-esc="value.name"/></span>
- <span t-if="value.count > 0" class="pull-right text-muted mr-2 mt-1 small"><t t-esc="value.count"/></span>
- <span t-if="filter.disableCounters" class="pull-right text-muted mr-2 mt-1 small">?</span>
+ <label t-att-for="inputId" class="o_search_panel_label custom-control-label" t-att-title="group &amp;&amp; group.tooltip">
+ <span class="o_search_panel_label_title" t-esc="value.display_name"/>
+ <span t-if="!filter.disableCounters and value.__count > 0"
+ class="text-muted ml-2 small"
+ t-esc="value.__count"
+ />
</label>
</div>
</li>
diff --git a/addons/web/static/tests/helpers/mock_server.js b/addons/web/static/tests/helpers/mock_server.js
index 6879c967b2f..6f6329525ab 100644
--- a/addons/web/static/tests/helpers/mock_server.js
+++ b/addons/web/static/tests/helpers/mock_server.js
@@ -528,117 +528,234 @@ var MockServer = Class.extend({
return modelFields;
},
/**
- * Simulate a call to the 'search_panel_select_range' method.
+ * Simulate a call to the search_panel_local_counters method.
+ * A model is added to the params to perform the adequate read_group.
*
- * Note that the implementation assumes that 'parent_id' is the field that
- * encodes the parent relationship.
+ * @param {string} model
+ * @param {string} fieldName the name of a field (type many2one or selection)
+ * @param {Object} kwargs
+ * @param {Array[]} kwargs.category_domain domain generated by categories
+ * @param {Array[]} kwargs.filter_domain domain generated by filters
+ * @param {Array[]} kwargs.group_domain domain generated by the field_name active values
+ * if field_name is used as a filter
+ * @param {Array[]} kwargs.search_domain base domain of search
+ * @returns {Object}
+ */
+ _mockSearchPanelLocalCounters: function (model, fieldName, kwargs) {
+ const countDomain = Domain.prototype.normalizeArray([
+ ...(kwargs.search_domain || []),
+ ...(kwargs.category_domain || []),
+ ...(kwargs.filter_domain || []),
+ ...(kwargs.group_domain || []),
+ [fieldName, '!=', false],
+ ]);
+ const field = this.data[model].fields[fieldName];
+ let getGroupId;
+ if (field.type === 'many2one') {
+ getGroupId = group => group[fieldName][0];
+ } else {
+ getGroupId = group => group[fieldName];
+ }
+ const groups = this._mockReadGroup(model, {
+ domain: countDomain,
+ fields: [fieldName],
+ groupby: [fieldName],
+ });
+ const localCounters = {};
+ for (const group of groups) {
+ const groupId = getGroupId(group);
+ localCounters[groupId] = group[fieldName + '_count'];
+ }
+ return localCounters;
+ },
+ /**
+ * Simulate a call to the search_panel_global_counters method.
+ *
+ * @param {Map} valuesRange
+ * @param {string} parentName
+ */
+ _mockSearchPanelGlobalCounters: function (valuesRange, parentName) {
+ const localCounters = [...valuesRange.keys()].map(id => valuesRange.get(id).__count);
+ for (let [id, values] of valuesRange.entries()) {
+ const count = localCounters[id];
+ if (count) {
+ let parent_id = values[parentName];
+ while (parent_id) {
+ values = valuesRange.get(parent_id);
+ values.__count += count;
+ parent_id = values[parentName];
+ }
+ }
+ }
+ },
+ /**
+ * Simulate a call to the `_search_panel_range` method.
+ * The `values_range` object being an OrderedDict in python, we use a map to
+ * have a close behaviour.
*
* @private
* @param {string} model
- * @param {Array} args
+ * @param {string} fieldName
+ * @param {Object} [kwargs={}]
+ * @param {Array[]} [kwargs.category_domain] domain generated by categories
+ * (this parameter is used in search_panel_local_counters)
+ * @param {Array[]} [kwargs.comodel_domain] domain of field values (if relational)
+ * (this parameter is used in search_panel_local_counters)
+ * @param {Array[]} [kwargs.comodel_field_names] a dict with keys given by field
+ * names of the comodel of field_name (if relational). The values are functions
+ * used to process the field names values. They must accept the single
+ * parameter record (see below).
+ * @param {Array[]} [kwargs.disable_counters] whether to count records by value.
+ * This is done in search_panel_local_counters
+ * @param {Array[]} [kwargs.filter_domain] domain generated by filters (this
+ * parameter is used in search_panel_local_counters)
+ * @param {Array[]} [kwargs.search_domain] base domain of search (this parameter
+ * is used in search_panel_local_counters)
+ * @returns {(Map|undefined)}
+ */
+ _mockSearchPanelRange: function (model, fieldName, kwargs = {}) {
+ const field = this.data[model].fields[fieldName];
+ let localCounters = {};
+ if (field.type !== 'many2many' && !kwargs.disable_counters) {
+ localCounters = this._mockSearchPanelLocalCounters(model, fieldName, kwargs);
+ }
+ if (['many2one', 'many2many'].includes(field.type)) {
+ const comodelDomain = kwargs.comodel_domain || [];
+ const fieldNames = kwargs.comodelFieldNames || {};
+ fieldNames.display_name = () => null;
+ const records = this._mockSearchRead(field.relation, [comodelDomain, Object.keys(fieldNames)], {});
+ const valuesRange = new Map();
+ for (const record of records) {
+ record.__count = localCounters[record.id] || 0;
+ Object.values(fieldNames).forEach(fn => fn(record));
+ valuesRange.set(record.id, record);
+ }
+ return valuesRange;
+ }
+ if (field.type === 'selection') {
+ const valuesRange = new Map();
+ const selection = this.data[model].fields[fieldName].selection;
+ for (const [id, display_name] of selection) {
+ valuesRange.set(id, { id, display_name, __count: localCounters[id] || 0 });
+ }
+ return valuesRange;
+ }
+ },
+ /**
+ * 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 {string[]} args
+ * @param {Object} [kwargs={}]
+ * @param {Array[]} [kwargs.category_domain] domain generated by categories
+ * (this parameter is used in _search_panel_range)
+ * @param {Array[]} [kwargs.comodel_domain] domain of field values (if relational)
+ * (this parameter is used in _search_panel_range)
+ * @param {boolean} [kwargs.disable_counters] whether to count records by value
+ * @param {Array[]} [kwargs.search_domain] base domain of search (this parameter
+ * is used in _search_panel_range)
* @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');
+ _mockSearchPanelSelectRange: function (model, [fieldName], kwargs = {}) {
+ const field = this.data[model].fields[fieldName];
+ const supportedTypes = ['many2one', 'selection'];
+ if (!supportedTypes.includes(field.type)) {
+ throw new Error(`Only types ${supportedTypes} are supported for category (found type ${field.type})`);
}
-
- var fields = ['display_name'];
- var parentField = this.data[field.relation].fields.parent_id;
- if (parentField) {
- fields.push('parent_id');
+ const comodelFieldNames = {};
+ let parentName = false;
+ if (field.type === 'many2one') {
+ if (this.data[field.relation].fields.parent_id) {
+ parentName = 'parent_id';
+ comodelFieldNames.parent_id = record => {
+ if (record.parent_id) {
+ record.parent_id = record.parent_id[0];
+ }
+ };
+ }
+ }
+ const rangeKwargs = Object.assign({ comodelFieldNames }, kwargs);
+ const valuesRange = this._mockSearchPanelRange(model, fieldName, rangeKwargs);
+ if (parentName && !kwargs.disable_counters) {
+ this._mockSearchPanelGlobalCounters(valuesRange, parentName);
}
return {
- parent_field: parentField ? 'parent_id' : false,
- values: this._mockSearchRead(field.relation, [[], fields], {}),
+ parent_field: parentName,
+ values: [...valuesRange.values()],
};
},
/**
* Simulate a call to the 'search_panel_select_multi_range' method.
- *
- * Note that only the many2one and selection cases are handled by this
- * function.
+ * Note that only the many2one and selection cases are handled by this function.
*
* @param {string} model
- * @param {Array} args
- * @param {Object} kwargs
- * @returns {Object}
+ * @param {string[]} args
+ * @param {Object} [kwargs={}]
+ * @param {Array[]} [kwargs.category_domain] domain generated by categories
+ * @param {Array[]} [kwargs.comodel_domain] domain of field values (if relational)
+ * (this parameter is used in _search_panel_range)
+ * @param {boolean} [kwargs.disable_counters] whether to count records by value
+ * @param {Array[]} [kwargs.filter_domain] domain generated by filters
+ * @param {string} [kwargs.group_by] extra field to read on comodel, to group
+ * comodel records
+ * @param {Array[]} [kwargs.group_domain] dict, one domain for each activated
+ * group for the group_by (if any). Those domains are used to fech accurate
+ * counters for values in each group
+ * @param {Array[]} [kwargs.search_domain] base domain of search
+ * @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');
+ _mockSearchPanelSelectMultiRange: function (model, [fieldName], kwargs = {}) {
+ const field = this.data[model].fields[fieldName];
+ const supportedTypes = ['many2one', 'many2many', 'selection'];
+ if (!supportedTypes.includes(field.type)) {
+ throw new Error(`Only types ${supportedTypes} are supported for filter (found type ${field.type})`);
}
-
- 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'];
- });
+ const comodelFieldNames = {};
+ const groupBy = kwargs.group_by || false;
+ if (groupBy && field.type !== 'selection') {
+ const groupByField = this.data[field.relation].fields[groupBy];
+ let groupIdName;
+ if (groupByField.type === 'many2one') {
+ groupIdName = value => value || [false, "Not Set"];
+ } else if (groupByField.type === 'selection') {
+ const desc = this.data[field.relation].fields[groupBy];
+ const groupBySelection = Object.assign({}, desc.selection);
+ groupBySelection[false] = "Not Set";
+ groupIdName = value => [value, groupBySelection[value]];
+ } else {
+ groupIdName = value => value ? [value, value] : [false, "Not Set"];
+ }
+ comodelFieldNames[groupBy] = record => {
+ const value = record[groupBy];
+ const [group_id, group_name] = groupIdName(value);
+ Object.assign(record, { group_id, group_name });
+ delete record[groupBy];
+ };
}
-
- // 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;
+ const rangeKwargs = Object.assign({ comodelFieldNames }, kwargs);
+ const valuesRange = this._mockSearchPanelRange(model, fieldName, rangeKwargs);
+ if (field.type === 'many2many' && !kwargs.disable_counters) {
+ const modelDomain = Domain.prototype.normalizeArray([
+ ...(kwargs.search_domain || []),
+ ...(kwargs.category_domain || []),
+ ...(kwargs.filter_domain || []),
+ [fieldName, '!=', false],
+ ]);
+ const groupDomains = kwargs.group_domain || {};
+ for (const [id, values] of valuesRange.entries()) {
+ let countDomain = Domain.prototype.normalizeArray([...modelDomain, [fieldName, 'in', id]]);
+ if (groupBy && groupDomains) {
+ const groupId = JSON.stringify(values.group_id);
+ const extraDomain = groupDomains[groupId] || [];
+ countDomain = Domain.prototype.normalizeArray([...countDomain, ...extraDomain]);
}
- 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],
- });
- });
+ values.__count = this._mockSearchCount(model, [countDomain]);
+ }
}
-
- return filterValues;
+ return [...valuesRange.values()];
},
/**
* Simulate a call to the '/web/action/load' route
diff --git a/addons/web/static/tests/views/search_panel_tests.js b/addons/web/static/tests/views/search_panel_tests.js
index 3c58a5a3923..f49b0a2bade 100644
--- a/addons/web/static/tests/views/search_panel_tests.js
+++ b/addons/web/static/tests/views/search_panel_tests.js
@@ -146,7 +146,7 @@ QUnit.module('Views', {
assert.containsN($firstSection, '.o_search_panel_category_value', 3);
assert.containsOnce($firstSection, '.o_search_panel_category_value:first .active');
assert.strictEqual($firstSection.find('.o_search_panel_category_value').text().replace(/\s/g, ''),
- 'Allasustekagrolait');
+ 'Allasustek2agrolait2');
var $secondSection = kanban.$('.o_search_panel_section:nth(1)');
assert.hasClass($secondSection.find('.o_search_panel_section_header i'), 'fa-filter');
@@ -455,7 +455,7 @@ QUnit.module('Views', {
});
QUnit.test('store and retrieve active category value', async function (assert) {
- assert.expect(8);
+ assert.expect(9);
var Storage = RamStorage.extend({
getItem: function (key) {
@@ -514,8 +514,9 @@ QUnit.module('Views', {
await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(2) header'));
assert.verifySteps([
- 'getItem searchpanel_partner_company_id',
+ 'getItem searchpanel_partner_company_id', // get on start
'setItem searchpanel_partner_company_id to 5',
+ 'getItem searchpanel_partner_company_id', // get on reload
]);
kanban.destroy();
@@ -867,7 +868,7 @@ QUnit.module('Views', {
assert.strictEqual(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold').length, 1,
"'agrolait' should be displayed as a parent category value");
- assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold'), 'fa-caret-left',
+ assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold'), 'fa-caret-right',
"'agrolait' should be folded");
assert.containsN(kanban, '.o_search_panel_category_value', 3);
@@ -879,7 +880,7 @@ QUnit.module('Views', {
// fold agrolait
await testUtils.dom.click(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold'));
- assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold'), 'fa-caret-left',
+ assert.hasClass(kanban.$('.o_search_panel_category_value:contains(agrolait) .o_toggle_fold'), 'fa-caret-right',
"'agrolait' should be folded");
assert.containsN(kanban, '.o_search_panel_category_value', 3);
@@ -1200,7 +1201,7 @@ QUnit.module('Views', {
assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
- 'asustek2agrolait');
+ 'asustek2agrolait1');
assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2);
// check 'agrolait'
@@ -1216,7 +1217,7 @@ QUnit.module('Views', {
assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
- 'asustekagrolait1');
+ 'asustek2agrolait1');
assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 1);
// uncheck 'agrolait'
@@ -1232,13 +1233,13 @@ QUnit.module('Views', {
'[]',
'[["bar","=",true]]',
// 'asustek' checked
- '[["company_id","in",[3]]]',
+ '[]',
'[["bar","=",true],["company_id","in",[3]]]',
// 'asustek' and 'agrolait' checked
- '[["company_id","in",[3,5]]]',
+ '[]',
'[["bar","=",true],["company_id","in",[3,5]]]',
// 'agrolait' checked
- '[["company_id","in",[5]]]',
+ '[]',
'[["bar","=",true],["company_id","in",[5]]]',
// nothing checked
'[]',
@@ -1307,7 +1308,7 @@ QUnit.module('Views', {
assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
- 'ABC1DEFGHI');
+ 'ABC1DEF1GHI1');
assert.containsOnce(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 1);
// check 'def'
@@ -1315,7 +1316,7 @@ QUnit.module('Views', {
assert.containsN(kanban, '.o_search_panel_filter_value input:checked', 2);
assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
- 'ABC1DEF1GHI');
+ 'ABC1DEF1GHI1');
assert.containsN(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)', 2);
// uncheck 'abc'
@@ -1323,7 +1324,7 @@ QUnit.module('Views', {
assert.containsOnce(kanban, '.o_search_panel_filter_value input:checked');
assert.strictEqual(kanban.$('.o_search_panel_filter_value').text().replace(/\s/g, ''),
- 'ABCDEF1GHI');
+ 'ABC1DEF1GHI1');
assert.containsOnce(kanban, '.o_kanban_view .o_kanban_record:not(.o_kanban_ghost)');
// uncheck 'def'
@@ -1339,13 +1340,13 @@ QUnit.module('Views', {
'[]',
'[["bar","=",true]]',
// 'asustek' checked
- '[["state","in",["abc"]]]',
+ '[]',
'[["bar","=",true],["state","in",["abc"]]]',
// 'asustek' and 'agrolait' checked
- '[["state","in",["abc","def"]]]',
+ '[]',
'[["bar","=",true],["state","in",["abc","def"]]]',
// 'agrolait' checked
- '[["state","in",["def"]]]',
+ '[]',
'[["bar","=",true],["state","in",["def"]]]',
// nothing checked
'[]',
@@ -1355,10 +1356,10 @@ QUnit.module('Views', {
kanban.destroy();
});
- QUnit.test('only reload filters when domains change', async function (assert) {
- assert.expect(11);
+ QUnit.test("only reload categories and filters when domains change (counters disabled, selection)", async function (assert) {
+ assert.expect(12);
- var kanban = await createView({
+ const kanban = await createView({
View: KanbanView,
model: 'partner',
data: this.data,
@@ -1381,7 +1382,75 @@ QUnit.module('Views', {
'partner,false,search': `
<search>
<searchpanel>
- <field name="state"/>
+ <field name="state" disable_counters="1"/>
+ <field select="multi" name="company_id"/>
+ </searchpanel>
+ </search>`,
+ },
+ viewOptions: {
+ limit: 2,
+ },
+ });
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ 'search_panel_select_multi_range',
+ '/web/dataset/search_read',
+ ]);
+
+ // go to page 2 (the domain doesn't change, so the filters should not be reloaded)
+ await cpHelpers.pagerNext(kanban);
+
+ assert.verifySteps([
+ '/web/dataset/search_read',
+ ]);
+
+ // reload with another domain, so the filters should be reloaded
+ await kanban.reload({domain: [['id', '<', 5]]});
+
+ assert.verifySteps([
+ 'search_panel_select_multi_range',
+ '/web/dataset/search_read',
+ ]);
+
+ // change category value, so the filters should be reloaded
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.verifySteps([
+ 'search_panel_select_multi_range',
+ '/web/dataset/search_read',
+ ]);
+
+ kanban.destroy();
+ });
+
+ QUnit.test("only reload categories and filters when domains change (counters disabled, many2one)", async function (assert) {
+ assert.expect(12);
+
+ const kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="category_id" disable_counters="1"/>
<field select="multi" name="company_id"/>
</searchpanel>
</search>`,
@@ -1392,6 +1461,7 @@ QUnit.module('Views', {
});
assert.verifySteps([
+ 'search_panel_select_range',
'search_panel_select_multi_range',
'/web/dataset/search_read',
]);
@@ -1422,6 +1492,206 @@ QUnit.module('Views', {
kanban.destroy();
});
+ QUnit.test('category counters', async function (assert) {
+ assert.expect(20);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (route === "/web/dataset/call_kw/partner/search_panel_select_range") {
+ assert.step(args.args[0]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="state"/>
+ <field name="company_id" disable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ viewOptions: {
+ limit: 2,
+ },
+ });
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ 'state',
+ 'search_panel_select_range',
+ 'company_id',
+ '/web/dataset/search_read',
+ ]);
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC1", "DEF1", "GHI2", "All", "asustek", "agrolait"]
+ );
+
+ // go to page 2 (the domain doesn't change, so the categories should not be reloaded)
+ await cpHelpers.pagerNext(kanban);
+
+ assert.verifySteps([
+ '/web/dataset/search_read',
+ ]);
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC1", "DEF1", "GHI2", "All", "asustek", "agrolait"]
+ );
+
+ // reload with another domain, so the category 'state' should be reloaded
+ await kanban.reload({domain: [['id', '<', 3]]});
+
+ assert.verifySteps([
+ "search_panel_select_range",
+ "state",
+ '/web/dataset/search_read',
+ ]);
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC1", "DEF1", "GHI", "All", "asustek", "agrolait"]
+ );
+
+ // change category value, so the category 'state' should be reloaded
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC1", "DEF1", "GHI", "All", "asustek", "agrolait"]
+ );
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ 'state',
+ '/web/dataset/search_read',
+ ]);
+
+ kanban.destroy();
+ });
+
+
+ QUnit.test('category selection without counters', async function (assert) {
+ assert.expect(14);
+
+ var kanban = await createView({
+ View: KanbanView,
+ model: 'partner',
+ data: this.data,
+ mockRPC: function (route, args) {
+ assert.step(args.method || route);
+ if (route === "/web/dataset/call_kw/partner/search_panel_select_range") {
+ assert.step(args.args[0]);
+ }
+ return this._super.apply(this, arguments);
+ },
+ services: this.services,
+ arch: `
+ <kanban>
+ <templates>
+ <t t-name="kanban-box">
+ <div>
+ <field name="foo"/>
+ </div>
+ </t>
+ </templates>
+ </kanban>`,
+ archs: {
+ 'partner,false,search': `
+ <search>
+ <searchpanel>
+ <field name="state" disable_counters="1"/>
+ </searchpanel>
+ </search>`,
+ },
+ viewOptions: {
+ limit: 2,
+ },
+ });
+
+ assert.verifySteps([
+ 'search_panel_select_range',
+ 'state',
+ '/web/dataset/search_read',
+ ]);
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC", "DEF", "GHI"]
+ );
+
+ // go to page 2 (the domain doesn't change, so the categories should not be reloaded)
+ await cpHelpers.pagerNext(kanban);
+
+ assert.verifySteps([
+ '/web/dataset/search_read',
+ ]);
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC", "DEF", "GHI"]
+ );
+
+ // reload with another domain, so the category 'state' should be reloaded
+ await kanban.reload({domain: [['id', '<', 3]]});
+
+ assert.verifySteps([
+ '/web/dataset/search_read',
+ ]);
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC", "DEF", "GHI"]
+ );
+
+ // change category value, so the category 'state' should be reloaded
+ await testUtils.dom.click(kanban.$('.o_search_panel_category_value:nth(1) header'));
+
+ assert.deepEqual(
+ [...kanban.el.querySelectorAll('.o_search_panel_category_value')].map(
+ e => e.innerText.replace(/\s/g, '')
+ ),
+ [ "All", "ABC", "DEF", "GHI"]
+ );
+
+ assert.verifySteps([
+ '/web/dataset/search_read',
+ ]);
+
+ kanban.destroy();
+ });
+
QUnit.test('filter with groupby', async function (assert) {
assert.expect(42);
@@ -1537,16 +1807,16 @@ QUnit.module('Views', {
'[]',
'[["bar","=",true]]',
// 'asustek' checked
- '[["company_id","in",[3]]]',
+ '[]',
'[["bar","=",true],["company_id","in",[3]]]',
// 'asustek' and 'agrolait' checked
- '[["company_id","in",[3]],["company_id","in",[5]]]',
+ '[]',
'[["bar","=",true],["company_id","in",[3]],["company_id","in",[5]]]',
// 'asustek', 'agrolait' and 'camptocamp' checked
- '[["company_id","in",[3]],["company_id","in",[5,11]]]',
+ '[]',
'[["bar","=",true],["company_id","in",[3]],["company_id","in",[5,11]]]',
// 'asustek' checked
- '[["company_id","in",[3]]]',
+ '[]',
'[["bar","=",true],["company_id","in",[3]]]',
]);
@@ -1571,6 +1841,7 @@ QUnit.module('Views', {
filter_domain: [],
search_domain: [],
comodel_domain: [['parent_id', '=', false]],
+ group_domain: [],
disable_counters: false,
});
}
@@ -1974,7 +2245,7 @@ QUnit.module('Views', {
var $firstSection = kanban.$('.o_search_panel_section:first');
assert.strictEqual($firstSection.find('.o_search_panel_category_value').text().replace(/\s/g, ''),
- 'AllasustekagrolaithighIDlowID');
+ 'Allasustek2agrolait2highIDlowID');
kanban.destroy();
});