diff options
| author | Mathieu Duckerts-Antoine <dam@odoo.com> | 2020-04-28 12:43:30 +0000 |
|---|---|---|
| committer | Mathieu Duckerts-Antoine <dam@odoo.com> | 2020-05-05 19:23:20 +0000 |
| commit | 2c102c292f22f5d400d8caf779428fdc55c15e43 (patch) | |
| tree | 6436980060455bd35619672d5e0ba693551e3902 | |
| parent | c19dcf3983c797d82cd808d9701e190aee619af2 (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>
| -rw-r--r-- | addons/web/models/models.py | 318 | ||||
| -rw-r--r-- | addons/web/static/src/js/views/search_panel.js | 170 | ||||
| -rw-r--r-- | addons/web/static/src/scss/search_panel.scss | 32 | ||||
| -rw-r--r-- | addons/web/static/src/xml/kanban.xml | 28 | ||||
| -rw-r--r-- | addons/web/static/tests/helpers/mock_server.js | 299 | ||||
| -rw-r--r-- | addons/web/static/tests/views/search_panel_tests.js | 321 | ||||
| -rw-r--r-- | doc/reference/views.rst | 13 | ||||
| -rw-r--r-- | odoo/addons/base/models/ir_module.py | 18 | ||||
| -rw-r--r-- | odoo/addons/test_search_panel/__init__.py | 3 | ||||
| -rw-r--r-- | odoo/addons/test_search_panel/__manifest__.py | 12 | ||||
| -rw-r--r-- | odoo/addons/test_search_panel/ir.model.access.csv | 5 | ||||
| -rw-r--r-- | odoo/addons/test_search_panel/models/__init__.py | 1 | ||||
| -rw-r--r-- | odoo/addons/test_search_panel/models/models.py | 46 | ||||
| -rw-r--r-- | odoo/addons/test_search_panel/tests/__init__.py | 3 | ||||
| -rw-r--r-- | odoo/addons/test_search_panel/tests/test_search_panel_select_multi_range.py | 407 | ||||
| -rw-r--r-- | odoo/addons/test_search_panel/tests/test_search_panel_select_range.py | 347 |
16 files changed, 1745 insertions, 278 deletions
diff --git a/addons/web/models/models.py b/addons/web/models/models.py index 702f8824895..2e8d63b55be 100644 --- a/addons/web/models/models.py +++ b/addons/web/models/models.py @@ -3,15 +3,22 @@ import babel.dates import pytz from lxml import etree import base64 +import json from odoo import _, api, fields, models from odoo.osv.expression import AND from odoo.tools import lazy from odoo.tools.misc import get_lang from odoo.exceptions import UserError +from collections import defaultdict SEARCH_PANEL_LIMIT = 200 +class lazymapping(defaultdict): + def __missing__(self, key): + value = self.default_factory(key) + self[key] = value + return value class IrActionsActWindowView(models.Model): _inherit = 'ir.actions.act_window.view' @@ -215,36 +222,201 @@ class Base(models.AbstractModel): r['arch'] = etree.tostring(etree.Element('qweb', root.attrib)) return r + + @api.model + def search_panel_local_counters(self, field_name, **kwargs): + """ + Return the record counts associated with the field_name values. + + :param field_name: the name of a field (type many2one or selection) + :param category_domain: domain generated by categories + :param filter_domain: domain generated by filters + :param group_domain: domain generated by the field_name active values + if field_name is used as a filter + :param search_domain: base domain of search + :return: a dict of the form {id: c,... } where id is a field_name value + and c is the associated record count + """ + count_domain = AND([ + kwargs.get('search_domain', []), + kwargs.get('category_domain', []), + kwargs.get('filter_domain', []), + kwargs.get('group_domain', []), + [(field_name, '!=', False)] + ]) + + field = self._fields[field_name] + + if field.type == 'many2one': + def get_group_id(group): + return group[field_name][0] + + else: + # field type is selection: see doc above + def get_group_id(group): + return group[field_name] + + groups = self.read_group(count_domain, [field_name], [field_name]) + local_counters = {} + for group in groups: + group_id = get_group_id(group) + local_counters[group_id] = group[field_name + '_count'] + + return local_counters + + + @api.model + def search_panel_global_counters(self, values_range, parent_name): + """ + Modify in place values_range to transform the (local) counts + into global counts (local count + children local counts) + in case a parent field parent_name has been set on the range values. + Note that we save the initial (local) counts into an auxiliary dict + before they could be changed in the for loop below. + + :param values_range: dict of the form + { + id: { 'id': id, '__count': c, parent_name: parent_id, ... } + ... + } + :param parent_name: string, indicates which key determines the parent + """ + local_counters = lazymapping(lambda id: values_range[id]['__count']) + + for id in values_range: + values = values_range[id] + # here count is the initial value = local count set on values + count = local_counters[id] + if count: + parent_id = values[parent_name] + while parent_id: + values = values_range[parent_id] + local_counters[parent_id] + values['__count'] += count + parent_id = values[parent_name] + + + @api.model + def _search_panel_range(self, field_name, **kwargs): + """ + Return possible values of the field field_name, + possibly with counters and other info (if the extra parameter + comodel_field_names has been provided). + + :param field_name: the name of a filter field; + possible types are many2one, many2many, selection. + :param category_domain: domain generated by categories + (this parameter is used in search_panel_local_counters) + :param comodel_domain: domain of field values (if relational) + (this parameter is used in search_panel_local_counters) + :param 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 disable_counters: whether to count records by value. This is done in + search_panel_local_counters + :param filter_domain: domain generated by filters + (this parameter is used in search_panel_local_counters) + :param search_domain: base domain of search + (this parameter is used in search_panel_local_counters) + :return: a list of possible values, each being a dict with keys + 'id' (value), + 'name' (value label), + '__count' (how many records with that value), + and possibly other keys + """ + field = self._fields[field_name] + + local_counters = {} + if field.type != 'many2many' and not kwargs.get('disable_counters'): + local_counters = self.search_panel_local_counters(field_name, **kwargs) + + if field.type in ['many2many', 'many2one']: + Comodel = self.env[field.comodel_name] + comodel_domain = kwargs.get('comodel_domain', []) + field_names = kwargs.get('comodel_field_names', {}) + # we fetch display_name but do not not process it + field_names['display_name'] = lambda record: None + + records = Comodel.with_context(hierarchical_naming=False).search_read(comodel_domain, field_names.keys(), limit=SEARCH_PANEL_LIMIT) + + values_range = {} + for record in records: + record_id = record['id'] + record['__count'] = local_counters.get(record_id, 0) + for fn in field_names.values(): + fn(record) + values_range[record_id] = record + return values_range + + if field.type == 'selection': + selection = self.fields_get([field_name])[field_name]['selection'] + + values_range = {} + for value, label in selection: + values_range[value] = { + 'id': value, + 'display_name': label, + '__count': local_counters.get(value, 0), + } + return values_range + + @api.model - def search_panel_select_range(self, field_name): + def search_panel_select_range(self, field_name, **kwargs): """ - Return possible values of the field field_name (case select="one") - and the parent field (if any) used to hierarchize them. + Return possible values of the field field_name (case select="one"), + possibly with counters and the parent field (if any) used to hierarchize them. - :param field_name: the name of a many2one category field + :param field_name: the name of a field; + of type many2one or selection. + :param category_domain: domain generated by categories + (this parameter is used in _search_panel_range) + :param comodel_domain: domain of field values (if relational) + (this parameter is used in _search_panel_range) + :param disable_counters: whether to count records by value + :param search_domain: base domain of search + (this parameter is used in _search_panel_range) :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. + The display name, the __count (how many records with that value) + and possibly parent_field are fetched. } """ field = self._fields[field_name] - supported_types = ['many2one'] + supported_types = ['many2one', 'selection'] 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) + field_names = {} + parent_name = False + if field.type == 'many2one': + Comodel = self.env[field.comodel_name] + if Comodel._parent_name in Comodel._fields: + parent_name = Comodel._parent_name + + def process_parent_field(record): + parent_id = record[parent_name] + if parent_id: + record[parent_name] = parent_id[0] + + field_names[parent_name] = process_parent_field + + values_range = self._search_panel_range(field_name, comodel_field_names=field_names, **kwargs) + + if parent_name and not kwargs.get('disable_counters') and len(values_range) < SEARCH_PANEL_LIMIT: + self.search_panel_global_counters(values_range, parent_name) + return { 'parent_field': parent_name, - 'values': Comodel.with_context(hierarchical_naming=False).search_read([], fields, limit=SEARCH_PANEL_LIMIT), + 'values': list(values_range.values()), } + @api.model def search_panel_select_multi_range(self, field_name, **kwargs): """ @@ -253,18 +425,22 @@ class Base(models.AbstractModel): :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 + (this parameter is used in _search_panel_range) :param disable_counters: whether to count records by value + :param filter_domain: domain generated by filters + :param group_by: extra field to read on comodel, to group comodel records + :param 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 search_domain: base domain of search :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). + '__count' (how many records with that value), + 'group_id' (value of group), set if a group_by has been provided, + 'group_name' (label of group), set if a group_by has been provided """ field = self._fields[field_name] supported_types = ['many2one', 'many2many', 'selection'] @@ -272,20 +448,12 @@ class Base(models.AbstractModel): 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 + field_names = {} + group_by = kwargs.get('group_by') + if group_by and field.type != 'selection': + # add group_by to the fields to fetch and tell + # how to process its values + Comodel = self.env.get(field.comodel_name) group_by_field = Comodel._fields[group_by] if group_by_field.type == 'many2one': @@ -304,67 +472,35 @@ class Base(models.AbstractModel): def group_id_name(value): return (value, value) if value else (False, _("Not Set")) - # get filter_values - filter_values = [] + def process_groupby_field(record): + value = record.pop(group_by) + record['group_id'], record['group_name'] = group_id_name(value) - 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, limit=SEARCH_PANEL_LIMIT) - 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, limit=SEARCH_PANEL_LIMIT) - 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), - }) + field_names[group_by] = process_groupby_field + + values_range = self._search_panel_range(field_name, comodel_field_names=field_names, **kwargs) + + if field.type == 'many2many' and not kwargs.get('disable_counters'): + # fetch the counts + model_domain = AND([ + kwargs.get('search_domain', []), + kwargs.get('category_domain', []), + kwargs.get('filter_domain', []), + [(field_name, '!=', False)] + ]) + group_domain = kwargs.get('group_domain', {}) + for id in values_range: + values = values_range[id] + + count_domain = AND([model_domain, [(field_name, 'in', id)], ]) + if group_by and group_domain: + group_id = json.dumps(values['group_id']) + extra_domain = group_domain.get(group_id, []) + count_domain = AND([count_domain, extra_domain, ]) + + values['__count'] = self.search_count(count_domain) - return filter_values + return list(values_range.values()) class ResCompany(models.Model): 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 && 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 && 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(); }); diff --git a/doc/reference/views.rst b/doc/reference/views.rst index 4dad4a4f726..a0253e321e9 100644 --- a/doc/reference/views.rst +++ b/doc/reference/views.rst @@ -1988,6 +1988,13 @@ Possible children elements of the search view are: * ``color``: determines the icon color + * ``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_range`` and ``search_panel_select_multi_range`` methods. + Additional optional attributes are available in the ``multi`` case: * ``domain``: determines conditions that the comodel records have to satisfy. @@ -2007,12 +2014,6 @@ Possible children elements of the search view are: * ``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. .. _reference/views/search/defaults: diff --git a/odoo/addons/base/models/ir_module.py b/odoo/addons/base/models/ir_module.py index 632188fadc9..e5780ed0467 100644 --- a/odoo/addons/base/models/ir_module.py +++ b/odoo/addons/base/models/ir_module.py @@ -921,7 +921,7 @@ class Module(models.Model): } @api.model - def search_panel_select_range(self, field_name): + def search_panel_select_range(self, field_name, **kwargs): if field_name == 'category_id': domain = [('module_ids', '!=', False)] @@ -946,13 +946,15 @@ class Module(models.Model): ]]) categories = self.env['ir.module.category'].search(domain) categories = categories | categories.mapped('parent_id') - return { - 'parent_field': 'parent_id', - 'values': self.env['ir.module.category'].search_read( - [('id', 'in', categories.ids)], - ['display_name', 'parent_id']), - } - return super(Module, self).search_panel_select_range(field_name) + + comodel_domain = [('id', 'in', categories.ids)] + + return super(Module, self).search_panel_select_range( + field_name, + comodel_domain=comodel_domain, + **kwargs + ) + return super(Module, self).search_panel_select_range(field_name, **kwargs) DEP_STATES = STATES + [('unknown', 'Unknown')] diff --git a/odoo/addons/test_search_panel/__init__.py b/odoo/addons/test_search_panel/__init__.py new file mode 100644 index 00000000000..cde864bae21 --- /dev/null +++ b/odoo/addons/test_search_panel/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- + +from . import models diff --git a/odoo/addons/test_search_panel/__manifest__.py b/odoo/addons/test_search_panel/__manifest__.py new file mode 100644 index 00000000000..10e8ac968af --- /dev/null +++ b/odoo/addons/test_search_panel/__manifest__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- +{ + 'name': "test_search_panel", + 'description': "Tests for the search panel python methods", + + 'category': 'Hidden/Tests', + 'version': '0.1', + + 'depends': ['web'], + + 'data': ['ir.model.access.csv'], +} diff --git a/odoo/addons/test_search_panel/ir.model.access.csv b/odoo/addons/test_search_panel/ir.model.access.csv new file mode 100644 index 00000000000..1bf35a9f47d --- /dev/null +++ b/odoo/addons/test_search_panel/ir.model.access.csv @@ -0,0 +1,5 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_test_search_panel_source_model,access_test_search_panel_source_model,model_test_search_panel_source_model,,1,0,0,0 +access_test_search_panel_category_target_model,access_test_search_panel_category_target_model,model_test_search_panel_category_target_model,,1,0,0,0 +access_test_search_panel_category_target_model_no_parent_name,access_test_search_panel_category_target_model_no_parent_name,model_test_search_panel_category_target_model_no_parent_name,,1,0,0,0 +access_test_search_panel_filter_target_model,access_test_search_panel_filter_target_model,model_test_search_panel_filter_target_model,,1,0,0,0 diff --git a/odoo/addons/test_search_panel/models/__init__.py b/odoo/addons/test_search_panel/models/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/odoo/addons/test_search_panel/models/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/odoo/addons/test_search_panel/models/models.py b/odoo/addons/test_search_panel/models/models.py new file mode 100644 index 00000000000..5be43b5bc52 --- /dev/null +++ b/odoo/addons/test_search_panel/models/models.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +from odoo import fields, models + + +class SourceModel(models.Model): + _name = 'test_search_panel.source_model' + _description = 'Source Model' + + name = fields.Char('Name', required=True) + state = fields.Selection([('a', "A"), ('b', "B")]) + folder_id = fields.Many2one('test_search_panel.category_target_model') + categ_id = fields.Many2one( + 'test_search_panel.category_target_model_no_parent_name') + tag_ids = fields.Many2many( + 'test_search_panel.filter_target_model', 'rel_table', string="Tags") + tag_id = fields.Many2one('test_search_panel.filter_target_model', string="Tag") + + +class CategoryTargetModel(models.Model): + _name = 'test_search_panel.category_target_model' + _order = 'name' + _description = 'Category target model' + _parent_name = 'parent_name_id' + + name = fields.Char('Name', required=True) + parent_name_id = fields.Many2one('test_search_panel.category_target_model') + + +class CategoryTargetModelNoParentName(models.Model): + _name = 'test_search_panel.category_target_model_no_parent_name' + _order = 'id desc' + _description = 'Category target model' + + name = fields.Char('Name', required=True) + + +class FilterTargetModel(models.Model): + _name = 'test_search_panel.filter_target_model' + _order = 'name' + _description = 'Filter target model' + + name = fields.Char('Name', required=True) + status = fields.Selection( + [('cool', "Cool"), ('unknown', 'Unknown')]) + color = fields.Char() + folder_id = fields.Many2one('test_search_panel.category_target_model') diff --git a/odoo/addons/test_search_panel/tests/__init__.py b/odoo/addons/test_search_panel/tests/__init__.py new file mode 100644 index 00000000000..0bdb214f61d --- /dev/null +++ b/odoo/addons/test_search_panel/tests/__init__.py @@ -0,0 +1,3 @@ +# -*- coding: utf-8 -*- +from . import test_search_panel_select_range +from . import test_search_panel_select_multi_range diff --git a/odoo/addons/test_search_panel/tests/test_search_panel_select_multi_range.py b/odoo/addons/test_search_panel/tests/test_search_panel_select_multi_range.py new file mode 100644 index 00000000000..aaef9060d7e --- /dev/null +++ b/odoo/addons/test_search_panel/tests/test_search_panel_select_multi_range.py @@ -0,0 +1,407 @@ +# -*- coding: utf-8 -*- +import odoo.tests +import json + +@odoo.tests.tagged('post_install', '-at_install') +class TestSelectRangeMulti(odoo.tests.TransactionCase): + + def setUp(self): + super().setUp() + self.SourceModel = self.env['test_search_panel.source_model'] + self.TargetModel = self.env['test_search_panel.filter_target_model'] + self.GroupByModel = self.env['test_search_panel.category_target_model'] + + # Many2one + + def test_many2one_empty(self): + result = self.SourceModel.search_panel_select_multi_range('tag_id') + self.assertEqual( + result, + [], + ) + + def test_many2one(self): + + folders = self.GroupByModel.create([ + {'name': 'Folder 1', }, + {'name': 'Folder 2', }, + ]) + + f1_id, f2_id = folders.ids + + tags = self.TargetModel.create([ + {'name': 'Tag 1', 'folder_id': f2_id, + 'color': 'Red', 'status': 'cool', }, + {'name': 'Tag 2', 'folder_id': f1_id, 'status': 'cool', }, + {'name': 'Tag 3', 'color': 'Green', 'status': 'cool', }, + ]) + + t1_id, t2_id, t3_id = tags.ids + + records = self.SourceModel.create([ + {'name': 'Rec 1', 'tag_id': t1_id, }, + {'name': 'Rec 2', 'tag_id': t1_id, }, + {'name': 'Rec 3', 'tag_id': t2_id, }, + {'name': 'Rec 4', }, + ]) + + r1_id, r2_id, _, _ = records.ids + + # counters + result = self.SourceModel.search_panel_select_multi_range('tag_id') + self.assertEqual( + result, + [ + {'__count': 2, 'display_name': 'Tag 1', 'id': t1_id, }, + {'__count': 1, 'display_name': 'Tag 2', 'id': t2_id, }, + {'__count': 0, 'display_name': 'Tag 3', 'id': t3_id, }, + ] + ) + + # counters and search domain + result = self.SourceModel.search_panel_select_multi_range( + 'tag_id', + search_domain=[['id', 'in', [r1_id, r2_id]]], + ) + self.assertEqual( + result, + [ + {'__count': 2, 'display_name': 'Tag 1', 'id': t1_id, }, + {'__count': 0, 'display_name': 'Tag 2', 'id': t2_id, }, + {'__count': 0, 'display_name': 'Tag 3', 'id': t3_id, }, + ], + ) + + # no counters + result = self.SourceModel.search_panel_select_multi_range( + 'tag_id', + disable_counters=True, + ) + self.assertEqual( + result, + [ + {'__count': 0, 'display_name': 'Tag 1', 'id': t1_id, }, + {'__count': 0, 'display_name': 'Tag 2', 'id': t2_id, }, + {'__count': 0, 'display_name': 'Tag 3', 'id': t3_id, }, + ], + ) + + # no counters and search domain + result = self.SourceModel.search_panel_select_multi_range( + 'tag_id', + disable_counters=True, + search_domain=[['id', 'in', [r1_id, r2_id]]], + ) + self.assertEqual( + result, + [ + {'__count': 0, 'display_name': 'Tag 1', 'id': t1_id, }, + {'__count': 0, 'display_name': 'Tag 2', 'id': t2_id, }, + {'__count': 0, 'display_name': 'Tag 3', 'id': t3_id, }, + ] + ) + + # many2one group_by + result = self.SourceModel.search_panel_select_multi_range( + 'tag_id', + group_by='folder_id' + ) + self.assertEqual( + result, + [ + {'__count': 2, 'display_name': 'Tag 1', 'id': t1_id, + 'group_id': f2_id, 'group_name': 'Folder 2', }, + {'__count': 1, 'display_name': 'Tag 2', 'id': t2_id, + 'group_id': f1_id, 'group_name': 'Folder 1', }, + {'__count': 0, 'display_name': 'Tag 3', 'id': t3_id, + 'group_id': False, 'group_name': 'Not Set', }, + ] + ) + + # selection group_by + result = self.SourceModel.search_panel_select_multi_range( + 'tag_id', + group_by='status' + ) + self.assertEqual( + result, + [ + {'__count': 2, 'display_name': 'Tag 1', 'id': t1_id, + 'group_id': 'cool', 'group_name': 'Cool', }, + {'__count': 1, 'display_name': 'Tag 2', 'id': t2_id, + 'group_id': 'cool', 'group_name': 'Cool', }, + {'__count': 0, 'display_name': 'Tag 3', 'id': t3_id, + 'group_id': 'cool', 'group_name': 'Cool', }, + ] + ) + + # other group_by + result = self.SourceModel.search_panel_select_multi_range( + 'tag_id', + group_by='color' + ) + self.assertEqual( + result, + [ + {'__count': 2, 'display_name': 'Tag 1', 'id': t1_id, + 'group_id': 'Red', 'group_name': 'Red', }, + {'__count': 1, 'display_name': 'Tag 2', 'id': t2_id, + 'group_id': False, 'group_name': 'Not Set', }, + {'__count': 0, 'display_name': 'Tag 3', 'id': t3_id, + 'group_id': 'Green', 'group_name': 'Green', }, + ] + ) + + # Many2many + + def test_many2many_empty(self): + result = self.SourceModel.search_panel_select_multi_range('tag_ids') + self.assertEqual( + result, + [], + ) + + def test_many2many(self): + + folders = self.GroupByModel.create([ + {'name': 'Folder 1', }, + {'name': 'Folder 2', }, + ]) + + f1_id, f2_id = folders.ids + + tags = self.TargetModel.create([ + {'name': 'Tag 1', 'folder_id': f2_id, + 'color': 'Red', 'status': 'cool', }, + {'name': 'Tag 2', 'folder_id': f1_id, 'status': 'cool', }, + {'name': 'Tag 3', 'color': 'Green', 'status': 'cool', }, + ]) + + t1_id, t2_id, t3_id = tags.ids + + records = self.SourceModel.create([ + {'name': 'Rec 1', 'tag_ids': [t1_id, t2_id, t3_id], }, + {'name': 'Rec 2', 'tag_ids': [t1_id], }, + {'name': 'Rec 3', 'tag_ids': [t2_id, t3_id], }, + {'name': 'Rec 4', }, + ]) + + r1_id, r2_id, _, _ = records.ids + + # counters + result = self.SourceModel.search_panel_select_multi_range('tag_ids') + self.assertEqual( + result, + [ + {'__count': 2, 'display_name': 'Tag 1', 'id': t1_id, }, + {'__count': 2, 'display_name': 'Tag 2', 'id': t2_id, }, + {'__count': 2, 'display_name': 'Tag 3', 'id': t3_id, }, + ] + ) + + # counters and search domain + result = self.SourceModel.search_panel_select_multi_range( + 'tag_ids', + search_domain=[['id', 'in', [r1_id, r2_id]]], + ) + self.assertEqual( + result, + [ + {'__count': 2, 'display_name': 'Tag 1', 'id': t1_id, }, + {'__count': 1, 'display_name': 'Tag 2', 'id': t2_id, }, + {'__count': 1, 'display_name': 'Tag 3', 'id': t3_id, }, + ], + ) + + # no counters + result = self.SourceModel.search_panel_select_multi_range( + 'tag_ids', + disable_counters=True, + ) + self.assertEqual( + result, + [ + {'__count': 0, 'display_name': 'Tag 1', 'id': t1_id, }, + {'__count': 0, 'display_name': 'Tag 2', 'id': t2_id, }, + {'__count': 0, 'display_name': 'Tag 3', 'id': t3_id, }, + ], + ) + + # no counters and search domain + result = self.SourceModel.search_panel_select_multi_range( + 'tag_ids', + disable_counters=True, + search_domain=[['id', 'in', [r1_id, r2_id]]], + ) + self.assertEqual( + result, + [ + {'__count': 0, 'display_name': 'Tag 1', 'id': t1_id, }, + {'__count': 0, 'display_name': 'Tag 2', 'id': t2_id, }, + {'__count': 0, 'display_name': 'Tag 3', 'id': t3_id, }, + ] + ) + + # many2one group_by + result = self.SourceModel.search_panel_select_multi_range( + 'tag_ids', + group_by='folder_id' + ) + self.assertEqual( + result, + [ + {'__count': 2, 'display_name': 'Tag 1', 'id': t1_id, + 'group_id': f2_id, 'group_name': 'Folder 2', }, + {'__count': 2, 'display_name': 'Tag 2', 'id': t2_id, + 'group_id': f1_id, 'group_name': 'Folder 1', }, + {'__count': 2, 'display_name': 'Tag 3', 'id': t3_id, + 'group_id': False, 'group_name': 'Not Set', }, + ] + ) + + # selection group_by + result = self.SourceModel.search_panel_select_multi_range( + 'tag_ids', + group_by='status' + ) + self.assertEqual( + result, + [ + {'__count': 2, 'display_name': 'Tag 1', 'id': t1_id, + 'group_id': 'cool', 'group_name': 'Cool', }, + {'__count': 2, 'display_name': 'Tag 2', 'id': t2_id, + 'group_id': 'cool', 'group_name': 'Cool', }, + {'__count': 2, 'display_name': 'Tag 3', 'id': t3_id, + 'group_id': 'cool', 'group_name': 'Cool', }, + ] + ) + + # other group_by + result = self.SourceModel.search_panel_select_multi_range( + 'tag_ids', + group_by='color' + ) + self.assertEqual( + result, + [ + {'__count': 2, 'display_name': 'Tag 1', 'id': t1_id, + 'group_id': 'Red', 'group_name': 'Red', }, + {'__count': 2, 'display_name': 'Tag 2', 'id': t2_id, + 'group_id': False, 'group_name': 'Not Set', }, + {'__count': 2, 'display_name': 'Tag 3', 'id': t3_id, + 'group_id': 'Green', 'group_name': 'Green', }, + ] + ) + + # group_domain and many2one groupby + result = self.SourceModel.search_panel_select_multi_range( + 'tag_ids', + group_by='folder_id', + group_domain={ + json.dumps(f1_id): [('tag_ids', 'in', [t1_id]), ], + json.dumps(f2_id): [('tag_ids', 'in', [t2_id]), ], + json.dumps(False): [('tag_ids', 'in', [t1_id]), ('tag_ids', 'in', [t2_id]), ], + } + ) + self.assertEqual( + result, + [ + {'__count': 1, 'display_name': 'Tag 1', 'id': t1_id, + 'group_id': f2_id, 'group_name': 'Folder 2', }, + {'__count': 1, 'display_name': 'Tag 2', 'id': t2_id, + 'group_id': f1_id, 'group_name': 'Folder 1', }, + {'__count': 1, 'display_name': 'Tag 3', 'id': t3_id, + 'group_id': False, 'group_name': 'Not Set', }, + ] + ) + + # group_domain and other group_by + result = self.SourceModel.search_panel_select_multi_range( + 'tag_ids', + group_by='color', + group_domain={ + json.dumps(False): [('tag_ids', 'in', [t1_id]), ], + json.dumps('Green'): [('tag_ids', 'in', [t1_id]), ], + } + ) + self.assertEqual( + result, + [ + {'__count': 2, 'display_name': 'Tag 1', 'id': t1_id, + 'group_id': 'Red', 'group_name': 'Red', }, + {'__count': 1, 'display_name': 'Tag 2', 'id': t2_id, + 'group_id': False, 'group_name': 'Not Set', }, + {'__count': 1, 'display_name': 'Tag 3', 'id': t3_id, + 'group_id': 'Green', 'group_name': 'Green', }, + ] + ) + + # Selection case + + def test_selection_empty(self): + result = self.SourceModel.search_panel_select_multi_range('state') + self.assertEqual( + result, + [ + {'display_name': 'A', 'id': 'a', '__count': 0, }, + {'display_name': 'B', 'id': 'b', '__count': 0, }, + ] + ) + + def test_selection(self): + records = self.SourceModel.create([ + {'name': 'Rec 1', 'state': 'a', }, + {'name': 'Rec 2', 'state': 'a', }, + ]) + + r1_id, _ = records.ids + + # counters + result = self.SourceModel.search_panel_select_multi_range('state') + self.assertEqual( + result, + [ + {'display_name': 'A', 'id': 'a', '__count': 2, }, + {'display_name': 'B', 'id': 'b', '__count': 0, }, + ] + ) + + # no counters + result = self.SourceModel.search_panel_select_multi_range( + 'state', + disable_counters=True, + ) + self.assertEqual( + result, + [ + {'display_name': 'A', 'id': 'a', '__count': 0, }, + {'display_name': 'B', 'id': 'b', '__count': 0, }, + ] + ) + + # counters and search domain + result = self.SourceModel.search_panel_select_multi_range( + 'state', + search_domain=[['id', '=', r1_id]], + ) + self.assertEqual( + result, + [ + {'display_name': 'A', 'id': 'a', '__count': 1, }, + {'display_name': 'B', 'id': 'b', '__count': 0, }, + ] + ) + + # no counters and search domain + result = self.SourceModel.search_panel_select_multi_range( + 'state', + disable_counters=True, + search_domain=[['id', '=', r1_id]], + ) + self.assertEqual( + result, + [ + {'display_name': 'A', 'id': 'a', '__count': 0, }, + {'display_name': 'B', 'id': 'b', '__count': 0, }, + ] + ) diff --git a/odoo/addons/test_search_panel/tests/test_search_panel_select_range.py b/odoo/addons/test_search_panel/tests/test_search_panel_select_range.py new file mode 100644 index 00000000000..2f3afb7f692 --- /dev/null +++ b/odoo/addons/test_search_panel/tests/test_search_panel_select_range.py @@ -0,0 +1,347 @@ +# -*- coding: utf-8 -*- +import odoo.tests + + +@odoo.tests.tagged('post_install', '-at_install') +class TestSelectRange(odoo.tests.TransactionCase): + + def setUp(self): + super().setUp() + self.SourceModel = self.env['test_search_panel.source_model'] + self.TargetModel = self.env['test_search_panel.category_target_model'] + self.TargetModelNoParentName = self.env['test_search_panel.category_target_model_no_parent_name'] + + # Many2one + + def test_many2one_empty(self): + result = self.SourceModel.search_panel_select_range('folder_id') + self.assertEqual( + result, + { + 'parent_field': 'parent_name_id', + 'values': [], + } + ) + + def test_many2one(self): + parent_folders = self.TargetModel.create([ + {'name': 'Folder 1', }, + {'name': 'Folder 2', }, + ]) + + f1_id, f2_id = parent_folders.ids + + children_folders = self.TargetModel.create([ + {'name': 'Folder 3', 'parent_name_id': f1_id, }, + {'name': 'Folder 4', 'parent_name_id': f2_id, }, + ]) + + f3_id, f4_id = children_folders.ids + + records = self.SourceModel.create([ + {'name': 'Rec 1', 'folder_id': f1_id, }, + {'name': 'Rec 2', 'folder_id': f3_id, }, + {'name': 'Rec 3', 'folder_id': f4_id, }, + {'name': 'Rec 4', }, + ]) + + r1_id, _, r3_id, _ = records.ids + + # counters + result = self.SourceModel.search_panel_select_range('folder_id') + self.assertEqual( + result['values'], + [ + {'__count': 2, 'display_name': 'Folder 1', + 'id': f1_id, 'parent_name_id': False, }, + {'__count': 1, 'display_name': 'Folder 2', + 'id': f2_id, 'parent_name_id': False, }, + {'__count': 1, 'display_name': 'Folder 3', + 'id': f3_id, 'parent_name_id': f1_id, }, + {'__count': 1, 'display_name': 'Folder 4', + 'id': f4_id, 'parent_name_id': f2_id, }, + ] + ) + + # counters and search domain + result = self.SourceModel.search_panel_select_range( + 'folder_id', + search_domain=[['id', 'in', [r1_id, r3_id]]], + ) + self.assertEqual( + result['values'], + [ + {'__count': 1, 'display_name': 'Folder 1', + 'id': f1_id, 'parent_name_id': False, }, + {'__count': 1, 'display_name': 'Folder 2', + 'id': f2_id, 'parent_name_id': False, }, + {'__count': 0, 'display_name': 'Folder 3', + 'id': f3_id, 'parent_name_id': f1_id, }, + {'__count': 1, 'display_name': 'Folder 4', + 'id': f4_id, 'parent_name_id': f2_id, }, + ] + ) + + # no counters + result = self.SourceModel.search_panel_select_range( + 'folder_id', + disable_counters=True, + ) + self.assertEqual( + result['values'], + [ + {'__count': 0, 'display_name': 'Folder 1', + 'id': f1_id, 'parent_name_id': False, }, + {'__count': 0, 'display_name': 'Folder 2', + 'id': f2_id, 'parent_name_id': False, }, + {'__count': 0, 'display_name': 'Folder 3', + 'id': f3_id, 'parent_name_id': f1_id, }, + {'__count': 0, 'display_name': 'Folder 4', + 'id': f4_id, 'parent_name_id': f2_id, }, + ] + ) + + # no counters and search domain + result = self.SourceModel.search_panel_select_range( + 'folder_id', + disable_counters=True, + search_domain=[['id', 'in', [r1_id, r3_id]]], + ) + self.assertEqual( + result['values'], + [ + {'__count': 0, 'display_name': 'Folder 1', + 'id': f1_id, 'parent_name_id': False, }, + {'__count': 0, 'display_name': 'Folder 2', + 'id': f2_id, 'parent_name_id': False, }, + {'__count': 0, 'display_name': 'Folder 3', + 'id': f3_id, 'parent_name_id': f1_id, }, + {'__count': 0, 'display_name': 'Folder 4', + 'id': f4_id, 'parent_name_id': f2_id, }, + ] + ) + + def test_many2one_deep_hierarchy(self): + folders_level_0 = self.TargetModel.create([ + {'name': 'Folder 1', }, + {'name': 'Folder 2', }, + {'name': 'Folder 3', }, + ]) + + f1_id, f2_id, f3_id = folders_level_0.ids + + folders_level_1 = self.TargetModel.create([ + {'name': 'Folder 4', 'parent_name_id': f1_id, }, + {'name': 'Folder 5', 'parent_name_id': f2_id, }, + {'name': 'Folder 6', 'parent_name_id': f2_id, }, + ]) + + f4_id, f5_id, f6_id = folders_level_1.ids + + folders_level_2 = self.TargetModel.create([ + {'name': 'Folder 7', 'parent_name_id': f4_id, }, + {'name': 'Folder 8', 'parent_name_id': f6_id, }, + ]) + + f7_id, f8_id = folders_level_2.ids + + folders_level_3 = self.TargetModel.create([ + {'name': 'Folder 9', 'parent_name_id': f8_id, }, + {'name': 'Folder 10', 'parent_name_id': f8_id, }, + ]) + + f9_id, f10_id = folders_level_3.ids + + self.SourceModel.create([ + {'name': 'Rec 1', 'folder_id': f1_id, }, + {'name': 'Rec 2', 'folder_id': f6_id, }, + {'name': 'Rec 3', 'folder_id': f7_id, }, + {'name': 'Rec 4', 'folder_id': f7_id, }, + {'name': 'Rec 5', 'folder_id': f9_id, }, + {'name': 'Rec 6', 'folder_id': f10_id, }, + ]) + + # counters + result = self.SourceModel.search_panel_select_range('folder_id') + self.assertEqual( + result['values'], + [ + {'__count': 3, 'display_name': 'Folder 1', + 'id': f1_id, 'parent_name_id': False, }, + {'__count': 1, 'display_name': 'Folder 10', + 'id': f10_id, 'parent_name_id': f8_id, }, + {'__count': 3, 'display_name': 'Folder 2', + 'id': f2_id, 'parent_name_id': False, }, + {'__count': 0, 'display_name': 'Folder 3', + 'id': f3_id, 'parent_name_id': False, }, + {'__count': 2, 'display_name': 'Folder 4', + 'id': f4_id, 'parent_name_id': f1_id, }, + {'__count': 0, 'display_name': 'Folder 5', + 'id': f5_id, 'parent_name_id': f2_id, }, + {'__count': 3, 'display_name': 'Folder 6', + 'id': f6_id, 'parent_name_id': f2_id, }, + {'__count': 2, 'display_name': 'Folder 7', + 'id': f7_id, 'parent_name_id': f4_id, }, + {'__count': 2, 'display_name': 'Folder 8', + 'id': f8_id, 'parent_name_id': f6_id, }, + {'__count': 1, 'display_name': 'Folder 9', + 'id': f9_id, 'parent_name_id': f8_id, }, + ] + ) + + # Many2one no parent name + + def test_many2one_empty_no_parent_name(self): + result = self.SourceModel.search_panel_select_range('categ_id') + self.assertEqual( + result, + { + 'parent_field': False, + 'values': [], + } + ) + + def test_many2one_no_parent_name(self): + categories = self.TargetModelNoParentName.create([ + {'name': 'Cat 1'}, + {'name': 'Cat 2'}, + {'name': 'Cat 3'}, + ]) + + c1_id, c2_id, c3_id = categories.ids + + records = self.SourceModel.create([ + {'name': 'Rec 1', 'categ_id': c1_id, }, + {'name': 'Rec 2', 'categ_id': c2_id, }, + {'name': 'Rec 3', 'categ_id': c2_id, }, + {'name': 'Rec 4', }, + ]) + + r1_id, _, r3_id, _ = records.ids + + # counters + result = self.SourceModel.search_panel_select_range('categ_id') + self.assertEqual( + result['values'], + [ + {'__count': 0, 'display_name': 'Cat 3', 'id': c3_id, }, + {'__count': 2, 'display_name': 'Cat 2', 'id': c2_id, }, + {'__count': 1, 'display_name': 'Cat 1', 'id': c1_id, }, + ] + ) + + # counters and search domain + result = self.SourceModel.search_panel_select_range( + 'categ_id', + search_domain=[['id', 'in', [r1_id, r3_id]]], + ) + self.assertEqual( + result['values'], + [ + {'__count': 0, 'display_name': 'Cat 3', 'id': c3_id, }, + {'__count': 1, 'display_name': 'Cat 2', 'id': c2_id, }, + {'__count': 1, 'display_name': 'Cat 1', 'id': c1_id, }, + ] + ) + + # no counters + result = self.SourceModel.search_panel_select_range( + 'categ_id', + disable_counters=True, + ) + self.assertEqual( + result['values'], + [ + {'__count': 0, 'display_name': 'Cat 3', 'id': c3_id, }, + {'__count': 0, 'display_name': 'Cat 2', 'id': c2_id, }, + {'__count': 0, 'display_name': 'Cat 1', 'id': c1_id, }, + ] + ) + + # no counters and search domain + result = self.SourceModel.search_panel_select_range( + 'categ_id', + disable_counters=True, + search_domain=[['id', 'in', [r1_id, r3_id]]], + ) + self.assertEqual( + result['values'], + [ + {'__count': 0, 'display_name': 'Cat 3', 'id': c3_id, }, + {'__count': 0, 'display_name': 'Cat 2', 'id': c2_id, }, + {'__count': 0, 'display_name': 'Cat 1', 'id': c1_id, }, + ] + ) + + # Selection case + + def test_selection_empty(self): + result = self.SourceModel.search_panel_select_range('state') + self.assertEqual( + result, + { + 'parent_field': False, + 'values': [ + {'display_name': 'A', 'id': 'a', '__count': 0, }, + {'display_name': 'B', 'id': 'b', '__count': 0, }, + ] + } + ) + + def test_selection(self): + records = self.SourceModel.create([ + {'name': 'Rec 1', 'state': 'a', }, + {'name': 'Rec 2', 'state': 'a', }, + ]) + + r1_id, _ = records.ids + + # counters + result = self.SourceModel.search_panel_select_range('state') + self.assertEqual( + result['values'], + [ + {'display_name': 'A', 'id': 'a', '__count': 2, }, + {'display_name': 'B', 'id': 'b', '__count': 0, }, + ] + ) + + # no counters + result = self.SourceModel.search_panel_select_range( + 'state', + disable_counters=True, + ) + self.assertEqual( + result['values'], + [ + {'display_name': 'A', 'id': 'a', '__count': 0, }, + {'display_name': 'B', 'id': 'b', '__count': 0, }, + ] + ) + + # counters and search domain + result = self.SourceModel.search_panel_select_range( + 'state', + search_domain=[['id', '=', r1_id]], + ) + self.assertEqual( + result['values'], + [ + {'display_name': 'A', 'id': 'a', '__count': 1, }, + {'display_name': 'B', 'id': 'b', '__count': 0, }, + ] + ) + + # no counters and search domain + result = self.SourceModel.search_panel_select_range( + 'state', + disable_counters=True, + search_domain=[['id', '=', r1_id]], + ) + self.assertEqual( + result['values'], + [ + {'display_name': 'A', 'id': 'a', '__count': 0, }, + {'display_name': 'B', 'id': 'b', '__count': 0, }, + ] + ) |
